Cosmos DB and MongoDB: NoSQL Databases for Flexible Schemas and Scale
DEV Community

Cosmos DB and MongoDB: NoSQL Databases for Flexible Schemas and Scale

Introduction

A practical guide to two of the most widely used NoSQL databases - Azure Cosmos DB and MongoDB - covering the document data model, schema flexibility, partitioning/sharding, consistency models, indexing, and how to choose between them (and against a relational database).

Cosmos DB and MongoDB are both document databases - instead of rows in fixed-schema tables, they store data as flexible, JSON-like documents that can vary in shape from one record to the next, and both are built from the ground up for horizontal scale across many machines rather than scaling a single powerful server.

{ "_id" : "42" , "name" : "Ada Lovelace" , "email" : "a**@example.com" ,
  "addresses" : [ { "type" : "home" , "city" : "London" },
                   { "type" : "work" , "city" : "Cambridge" } ],
  "preferences" : { "newsletter" : true , "theme" : "dark" } }

That single document - a customer with a variable number of addresses and an open-ended preferences object - would take a join across two or three normalized tables in a relational database. In a document database, it's one record, fetched in one read.

MongoDB is the open-source, self-hostable (or MongoDB Atlas-managed) document database that popularized this model at scale. Azure Cosmos DB is Microsoft's globally distributed, fully managed database service - natively a document database (its "Core (SQL) API"), but also capable of speaking MongoDB's own wire protocol, Cassandra's, Gremlin's (graph), and Azure Table Storage's, all through the same underlying engine.

1. Why NoSQL, and Why Document Databases Specifically

Schema flexibility
Relational databases require every row in a table to share the same columns (with NULL for anything not applicable). Document databases let each document have its own shape - useful when different records genuinely have different attributes (a product catalog spanning wildly different product types), or when the schema is expected to evolve frequently and you don't want a migration for every new optional field.

{ "type" : "book" , "title" : "..." , "author" : "..." , "isbn" : "..." }
{ "type" : "laptop" , "brand" : "..." , "cpu" : "..." , "ramGb" : 16 }

Both documents can live in the same collection/container without either needing columns the other doesn't use.

Horizontal scale by design
Where a relational database's default scaling story is "get a bigger server" (vertical scaling), document databases are architected from the start to shard data across many servers (horizontal scaling) - a single logical collection can span dozens or hundreds of physical machines, each holding a subset of the data, with the database routing queries to the right shard(s) automatically.

The tradeoff: less enforced structure, fewer built-in cross-document guarantees
The flexibility document databases provide is also their biggest risk if unmanaged - with no schema enforced by the database itself, inconsistent document shapes can accumulate unnoticed, and multi-document transactional guarantees (while now supported to varying degrees in both systems) are historically weaker and often deliberately avoided for performance reasons, pushing more correctness responsibility onto application-level data modeling discipline (Section 8) than a relational schema and foreign keys would.

2. The Document Data Model

Collections/containers instead of tables
MongoDB groups documents into collections within a database. Cosmos DB groups documents into containers within a database. Neither requires documents in the same collection/container to share a fixed schema - though in practice, most real applications keep documents within one collection reasonably consistent in shape, even without the database enforcing it, simply because application code needs to reason about them predictably.

_id as the primary key

{ "_id" : ObjectId( "507f1f77bcf86cd799439011" ) , "name" : "Wireless Mouse" }

Both systems use _id as the document's unique identifier. MongoDB defaults to a generated ObjectId (a 12-byte value encoding a timestamp and other bits) if you don't supply one; Cosmos DB's Core (SQL) API accepts any string you choose as id, and requires it alongside a partition key (Section 5) for efficient routing.

Nested structures are native, not an afterthought

{ "orderId" : "1001" ,
  "customer" : { "name" : "Ada Lovelace" , "email" : "a**@example.com" },
  "items" : [ { "productId" : "42" , "quantity" : 2 , "price" : 29.99 },
              { "productId" : "17" , "quantity" : 1 , "price" : 89.99 } ],
  "total" : 149.97 }

An entire order - customer info and line items included - lives as one document, retrievable in a single read with no joins, which is exactly the shape that tends to make document databases fast for read-heavy, aggregate-oriented access patterns (see Section 8 for when this is and isn't the right modeling choice).

3. MongoDB

Basic CRUD

db.products.insertOne({ name: "Wireless Mouse", price: 29.99, category: "electronics" });
db.products.find({ category: "electronics", price: { $lt: 50 } });
db.products.updateOne(
  { _id: ObjectId("...") },
  { $set: { price: 24.99 }, $push: { tags: "sale" } }
);
db.products.deleteOne({ _id: ObjectId("...") });

The aggregation pipeline
MongoDB's aggregation pipeline is its primary tool for anything beyond simple filtering - a sequence of stages, each transforming the documents flowing through it, conceptually similar to a LINQ method chain or a SQL query broken into explicit steps:

db.orders.aggregate([
  { $match: { status: "completed" } },
  { $unwind: "$items" },
  { $group: { _id: "$items.productId",
              totalRevenue: { $sum: { $multiply: ["$items.price", "$items.quantity"] } },
              unitsSold: { $sum: "$items.quantity" }
  }},
  { $sort: { totalRevenue: -1 } },
  { $limit: 10 }
]);

This computes top-10 products by revenue across all completed orders - $match filters, $unwind flattens the items array into one document per line item, $group aggregates by product, and $sort/$limit finish it off. The pipeline model makes complex, multi-step transformations explicit and composable, similar in spirit to a series of chained LINQ operators.

Multi-document transactions

const session = client.startSession();
session.startTransaction();
try {
  await accounts.updateOne({ _id: fromId }, { $inc: { balance: -100 } }, { session });
  await accounts.updateOne({ _id: toId }, { $inc: { balance: 100 } }, { session });
  await session.commitTransaction();
} catch (error) {
  await session.abortTransaction();
  throw error;
} finally {
  session.endSession();
}

MongoDB has supported full ACID multi-document transactions since version 4.0 (within a replica set) and across sharded clusters since 4.2 - a significant evolution from MongoDB's early reputation as offering only single-document atomicity, though multi-document transactions still carry a real performance cost relative to well-modeled single-document operations, and good data modeling (Section 8) that minimizes the need for them remains the preferred first choice where it fits the access pattern.

Deployment options

  • Self-hosted - run MongoDB yourself, on VMs, bare metal, or in containers/Kubernetes.
  • MongoDB Atlas - MongoDB's own fully managed, multi-cloud DBaaS (available on Azure, AWS, and GCP), handling provisioning, scaling, backups, and patching.

4. Azure Cosmos DB

A multi-model, globally distributed database service
Cosmos DB's defining architectural pitch is global distribution with configurable consistency, available transparently regardless of which API surface you use to talk to it.

CosmosClient client = new CosmosClient(connectionString);
Container container = client.GetContainer("StoreDb", "Products");
var product = new Product { Id = "42", Name = "Wireless Mouse", Price = 29.99m, CategoryId = "electronics" };
await container.CreateItemAsync(product, new PartitionKey(product.CategoryId));

var query = container.GetItemQueryIterator<Product>(
    new QueryDefinition("SELECT * FROM c WHERE c.categoryId = @category")
        .WithParameter("@category", "electronics"));
while (query.HasMoreResults)
{
    foreach (var item in await query.ReadNextAsync())
        Console.WriteLine(item.Name);
}

Multiple APIs, one underlying engine

API What it looks like to clients
Core (SQL) API Native document model, queried with a SQL-like dialect (see below)
API for MongoDB Speaks the MongoDB wire protocol - many MongoDB drivers/tools work against it with minimal changes
API for Cassandra Speaks the Cassandra Query Language (CQL) wire protocol
API for Gremlin Graph queries via the Apache TinkerPop Gremlin traversal language
API for Table Compatible with Azure Table Storage's API, with more throughput/global distribution options

This means an application already using MongoDB drivers can, in principle, point them at Cosmos DB's MongoDB API endpoint instead of a MongoDB server - though compatibility is close but not 100% identical to native MongoDB feature-for-feature, so this migration path needs real validation, not an assumption of a drop-in swap.

SQL-like query language (Core API)

SELECT c.name, c.price FROM c
WHERE c.categoryId = "electronics" AND c.price < 50
ORDER BY c.price DESC

Despite the SQL-like syntax, this queries a schema-less document container, not a relational table - c refers to each document ("item") in the container, and the query engine navigates nested properties directly (c.address.city, for instance) without needing a join.

Global distribution

// Configuring multi-region writes in Bicep/ARM, conceptually:
// locations: [ { locationName: "East US", failoverPriority: 0 },
//              { locationName: "West Europe", failoverPriority: 1 } ]

A Cosmos DB account can be replicated to any number of Azure regions worldwide with a few clicks/lines of IaC, with multi-region writes available so applications in different regions can write locally with low latency, and Cosmos DB handles propagating and (per the chosen consistency model, Section 6) reconciling those writes globally.

Request Units (RUs): the throughput/cost currency
Cosmos DB doesn't bill by raw CPU/memory the way a VM-based database does - every operation (read, write, query) costs a measured number of Request Units, and you provision (or let autoscale handle) RU/s capacity that your workload's operations draw from:

az cosmosdb sql container create --account-name my-account --database-name StoreDb \
  --name Products --partition-key-path "/categoryId" --throughput 400

This RU-based cost model is genuinely distinctive versus most other databases - it's precise (you can see exactly how many RUs any given query consumed) but requires learning a new mental model for both performance tuning and cost estimation, since an inefficient query design (e.g., a cross-partition scan, Section 5) shows up directly as a higher RU cost, not just as "slow."

5. Partitioning and Sharding

MongoDB: sharding

sh.enableSharding("StoreDb");
sh.shardCollection("StoreDb.orders", { customerId: "hashed" });

MongoDB distributes a sharded collection across multiple shards based on a chosen shard key - a hashed shard key (as above) spreads writes evenly across shards, while a ranged shard key groups related values together (useful when range queries on the shard key are common), at the cost of potential "hot shard" issues if writes cluster around a narrow range of key values.

Cosmos DB: partitioning

--partition-key-path "/categoryId"

Every Cosmos DB container requires a partition key chosen at creation time (and, in modern Cosmos DB, changeable later via a partition key migration, though it's still a meaningful decision to get right upfront) - it determines how documents are distributed across physical partitions, and it's the single most consequential data modeling decision in a Cosmos DB design.

Choosing a good partition/shard key

A good key:

  • Distributes writes evenly - avoid a key with a small number of very common values (e.g., a status field with only 3 possible values) which concentrates load on a few partitions/shards.
  • Matches your most common query filter - queries that include the partition key can be routed directly to the relevant partition(s); queries that omit it become expensive cross-partition (fan-out) queries that touch every partition.
  • Has high enough cardinality - a key like customerId (many distinct values) usually distributes better than something coarse like region (few distinct values, each potentially very large).
-- Efficient: includes the partition key, routed to a single partition
SELECT * FROM c WHERE c.categoryId = "electronics" AND c.price < 50

-- Expensive: no partition key filter, fans out across every partition
SELECT * FROM c WHERE c.price < 50

Getting the partition key wrong is one of the most common and most expensive-to-fix mistakes in both systems - it's usually a foundational, largely one-time decision (though both systems have added tooling to ease repartitioning after the fact), so it's worth real design effort upfront rather than an afterthought.

6. Consistency Models

MongoDB: read concerns and write concerns

db.orders.find().readConcern("majority"); // reads data acknowledged by a majority of replica set members
db.orders.insertOne(doc, { writeConcern: { w: "majority" } }); // waits for majority acknowledgment before returning

MongoDB lets you tune consistency/durability per-operation via read concern (how consistent/durable the data you're reading must be) and write concern (how many replicas must acknowledge a write before it's con

Comments

No comments yet. Start the discussion.