Problem Context
MongoDB shipped version 8.0 in October 2024 and 8.2 in 2025, finally adding queryable encryption GA, time-series collection improvements, and a query engine rewrite that closed many of the historical performance gaps with relational systems on aggregation-heavy workloads. The license remains SSPL (since 2018), which keeps it out of major cloud catalogues โ Azure ships Cosmos DB for MongoDB (vCore), AWS ships DocumentDB, both of which mimic the wire protocol.
The 2026 question is no longer "SQL or NoSQL?" โ it's "document-shaped or row-shaped data?". MongoDB earns its place when you have nested aggregates that are read together, schema that varies by tenant, or working sets that fit RAM and need sub-millisecond reads with secondary indexes. It loses when you need multi-table joins, strict referential integrity, or complex analytics โ Postgres + JSONB has narrowed the gap dramatically.
- You modeled deeply nested arrays and now updates are atomic-on-paper but expensive in practice
- You don't know when to embed and when to reference
- You learned
find()but never wrote an aggregation pipeline - You discovered that MongoDB has multi-document transactions but they're slow โ and you're not sure why
Six rules for document modeling and one mental model for the aggregation pipeline cover most production work.
Concept Explanation
MongoDB stores BSON documents (binary JSON with extra types โ Date, Decimal128, ObjectId) inside collections. The schema is enforced at insert time via JSON Schema validators (optional but recommended) rather than ahead of time. Two design decisions dominate:
- Embed vs referenceโ embed when read together and bounded; reference when entities have independent lifecycles or unbounded growth (a user's comments, an order's line items vs a product's reviews).
- Indexes โ same B-tree story as SQL, plus compound, partial, TTL, text, geospatial, and wildcard indexes. Atlas Search adds a Lucene index alongside.
flowchart LR
M["$match<br/>(filter early)"] --> P["$project<br/>(reshape)"]
P --> L["$lookup<br/>(left join)"]
L --> G["$group<br/>(aggregate)"]
G --> S["$sort + $limit<br/>(top N)"]
S --> O["$out / $merge<br/>(materialize)"]
style M fill:#0078D4,color:#fff,stroke:#005a9e
style O fill:#16a34a,color:#fff,stroke:#15803d
Implementation
Step 1: Connect from C# (MongoDB.Driver 3.x)
// MongoDB.Driver 3.0+ (2024) uses the new LINQ provider by default
var client = new MongoClient(connStr);
var db = client.GetDatabase("shop");
var orders = db.GetCollection<Order>("orders");
public class Order
{
[BsonId] public ObjectId Id { get; set; }
public string CustomerId { get; set; } = "";
public List<LineItem> Items { get; set; } = new();
public decimal TotalCents { get; set; }
public DateTime CreatedAt { get; set; }
}Step 2: Embed bounded children, reference unbounded ones
// GOOD: order has a small, bounded list of line items โ embed
var order = new Order {
CustomerId = "c1",
Items = new() {
new LineItem { Sku = "ABC", Qty = 2, PriceCents = 999 }
},
TotalCents = 1998,
CreatedAt = DateTime.UtcNow
};
await orders.InsertOneAsync(order);
// BAD: a user's comments are unbounded โ reference instead
// Store { _id, userId, postId, body } in its own 'comments' collection
// and index on postId.Step 3: Indexes for your actual queries
// Compound index โ order matters! Equality, then sort, then range.
await orders.Indexes.CreateOneAsync(
new CreateIndexModel<Order>(
Builders<Order>.IndexKeys
.Ascending(o => o.CustomerId)
.Descending(o => o.CreatedAt)));
// Partial index โ only the rows you filter on
await orders.Indexes.CreateOneAsync(
new CreateIndexModel<Order>(
Builders<Order>.IndexKeys.Ascending("status"),
new CreateIndexOptions<Order> {
PartialFilterExpression = Builders<Order>.Filter.Eq("status", "open")
}));
// TTL index โ auto-delete sessions after 24h
await sessions.Indexes.CreateOneAsync(
new CreateIndexModel<Session>(
Builders<Session>.IndexKeys.Ascending(s => s.CreatedAt),
new CreateIndexOptions { ExpireAfter = TimeSpan.FromHours(24) }));Step 4: Aggregation pipeline (the real query language)
// Top 5 customers by spend in the last 30 days
var since = DateTime.UtcNow.AddDays(-30);
var top = await orders.Aggregate()
.Match(o => o.CreatedAt >= since)
.Group(
o => o.CustomerId,
g => new { CustomerId = g.Key,
Spend = g.Sum(o => o.TotalCents),
Orders = g.Count() })
.SortByDescending(x => x.Spend)
.Limit(5)
.ToListAsync();Step 5: Atomic updates (no transactions needed)
// Atomic: increment quantity if SKU exists in array
var filter = Builders<Order>.Filter.And(
Builders<Order>.Filter.Eq(o => o.Id, orderId),
Builders<Order>.Filter.Eq("Items.Sku", "ABC"));
var update = Builders<Order>.Update.Inc("Items.$.Qty", 1);
await orders.UpdateOneAsync(filter, update);
// findOneAndUpdate for read-modify-write in one round trip
var updated = await orders.FindOneAndUpdateAsync(
o => o.Id == orderId,
Builders<Order>.Update.Set(o => o.Status, "shipped"),
new FindOneAndUpdateOptions<Order> {
ReturnDocument = ReturnDocument.After
});Step 6: Multi-document transactions (only when you must)
using var session = await client.StartSessionAsync();
session.StartTransaction();
try
{
await orders.UpdateOneAsync(session, o => o.Id == oid,
Builders<Order>.Update.Set(o => o.Status, "paid"));
await ledger.InsertOneAsync(session, new Ledger { OrderId = oid });
await session.CommitTransactionAsync();
}
catch
{
await session.AbortTransactionAsync();
throw;
}
// Transactions cost ~10x a single update. Most "transactional" needs in
// MongoDB are solved by remodeling so the atom is one document.Common Pitfalls
- Modeling like SQL. Three normalized collections joined with
$lookupon every read = slow MongoDB and an unhappy team. Either embed or pick a relational engine. - Unbounded arrays. Embedding a comments array on a viral post hits the 16 MB document cap and rewrites the entire doc on every push. Reference instead.
- Missing indexes. Default queries do a COLLSCAN. Run
db.coll.find(...).explain("executionStats")and add covering compound indexes. - Wrong write concern. Default
w:majorityis durable but slower.w:1can lose data on primary failure. Pick deliberately per workload. - Read-your-write surprise. Reading from a secondary right after a primary write can return stale data. Use
readPreference=primaryorreadConcern=majoritywhen you need read-your-write. - Schema drift. Without a JSON Schema validator, fields silently morph types across releases. Add validators and migrate explicitly.
Practical Takeaways
- Document modeling is a design skill: embed when read together and bounded; reference otherwise.
- Atomic single-document updates are MongoDB's superpower โ use
$inc,$push, positional$, andfindOneAndUpdate. - Aggregation pipelines are the real query language. Filter (
$match) early, project late. - Compound indexes follow ESR: Equality, Sort, Range โ in that order.
- Multi-document transactions exist but cost ~10x; remodel to a single document if you can.
- Use Atlas Search (or Cosmos vCore) when you need full-text or vector search instead of bolting on Elasticsearch.
- Run
explain()on every important query before you ship it.

