"NoSQL" isn't one thing, and treating DynamoDB, Cosmos DB, and MongoDB as interchangeable is how teams end up with a data model that fights the database instead of working with it. Each of these makes a different bet about what you'll query by, how you'll scale, and what consistency guarantees cost you. Here's how we think about the choice from a .NET codebase.
- Partition (or shard) key design is the single highest-leverage decision in any of these three — get it wrong and you're looking at a data migration, not a config change.
- DynamoDB and Cosmos DB are managed and billed around throughput; MongoDB (self-hosted or Atlas) gives you richer ad-hoc querying at the cost of more operational ownership.
- Default consistency differs in ways that matter: DynamoDB is eventually consistent unless you opt into strongly consistent reads; Cosmos DB gives you five tunable levels; MongoDB is strongly consistent for single-document operations by default.
- Reaching for NoSQL because it's "more modern" is how teams end up re-implementing joins in application code. Reach for it when you have a specific access-pattern or scale problem a relational store doesn't solve cleanly.
Start from the access pattern, not the data model
Relational design starts with the entities and normalizes; NoSQL design starts with the queries you'll actually run and shapes the data to answer them cheaply. If you can't list your top five access patterns before you open the SDK docs, that's the actual first step — not picking a database.
DynamoDB from .NET
DynamoDB's object persistence model maps client-side classes directly to table items via DynamoDBContext, which keeps the code close to what you'd write against EF Core.
[DynamoDBTable("Orders")]
public class OrderItem
{
[DynamoDBHashKey("PK")]
public string CustomerId { get; set; } = string.Empty;
[DynamoDBRangeKey("SK")]
public string OrderId { get; set; } = string.Empty;
public decimal Total { get; set; }
public string Status { get; set; } = "Draft";
}
var context = new DynamoDBContext(new AmazonDynamoDBClient());
await context.SaveAsync(new OrderItem
{
CustomerId = "cust-482",
OrderId = "ord-9931",
Total = 128.50m,
Status = "Submitted"
});
var orders = await context.QueryAsync<OrderItem>("cust-482").GetRemainingAsync();
The partition key (CustomerId here) determines how DynamoDB distributes your data across storage partitions — every query without an exact partition key match becomes a scan, which is slow and expensive at any real scale. The single-table design pattern (multiple entity types sharing one table, distinguished by key prefixes) is the DynamoDB-idiomatic way to model one-to-many and many-to-many relationships without a join; it looks strange coming from relational modeling, but it's what the database is actually optimized for.
Cosmos DB from .NET
The Microsoft.Azure.Cosmos SDK gives you a similar shape, with the partition key chosen explicitly at container creation and every request routed by it.
public class OrderDocument
{
public string id { get; set; } = Guid.NewGuid().ToString();
public string CustomerId { get; set; } = string.Empty;
public decimal Total { get; set; }
public string Status { get; set; } = "Draft";
}
var client = new CosmosClient(connectionString);
var container = client.GetContainer("shop", "orders");
await container.CreateItemAsync(
new OrderDocument { CustomerId = "cust-482", Total = 128.50m, Status = "Submitted" },
new PartitionKey("cust-482"));
var query = container.GetItemLinqQueryable<OrderDocument>()
.Where(o => o.CustomerId == "cust-482");
Cosmos DB's distinguishing feature is five tunable consistency levels — Strong, Bounded Staleness, Session, Consistent Prefix, and Eventual — set at the account level but overridable per request. Session (the default) guarantees you'll always read your own writes without paying for global strong consistency, which is the right default for most application code.
MongoDB from .NET
The MongoDB.Driver package trades the partition-key rigidity of the other two for a document model with genuinely flexible ad-hoc querying and aggregation.
public class Order
{
public ObjectId Id { get; set; }
public string CustomerId { get; set; } = string.Empty;
public decimal Total { get; set; }
public string Status { get; set; } = "Draft";
}
var client = new MongoClient(connectionString);
var collection = client.GetDatabase("shop").GetCollection<Order>("orders");
await collection.InsertOneAsync(new Order { CustomerId = "cust-482", Total = 128.50m, Status = "Submitted" });
var orders = await collection.Find(o => o.CustomerId == "cust-482" && o.Total > 100)
.SortByDescending(o => o.Total)
.ToListAsync();
MongoDB's aggregation pipeline handles the kind of multi-condition, ad-hoc filtering and grouping that would require a scan or a secondary index in DynamoDB. That flexibility comes with sharding being something you actively manage (or pay Atlas to manage) rather than a built-in property of every table.
Consistency, side by side
| DynamoDB | Cosmos DB | MongoDB | |
|---|---|---|---|
| Default | Eventually consistent | Session | Strong (single document) |
| Strong consistency | Opt-in per read, single-region | Opt-in, costs more RUs | Default for single-doc ops |
| Query flexibility | Key-driven, limited scans | SQL-like query language | Rich aggregation pipeline |
| Operational model | Fully managed, serverless billing | Fully managed, serverless or provisioned | Self-hosted or Atlas managed |
When a relational database is still the better call
If your access patterns involve ad-hoc joins across many entities, multi-record ACID transactions, or you don't yet know your query patterns well enough to design a partition key, a relational database with good indexing will serve you better and with less operational surprise. NoSQL earns its complexity when you have a specific, known access pattern or scale requirement it solves more cleanly than a relational store — not as a default.