The final base post wraps up the series with two practical halves. First — a quick tour of the major drivers (Node official + Mongoose vs Prisma, Python pymongo + Motor, Go, Rust, .NET) so you can match idioms to the language you actually ship in. Second — end-to-end modelling case studies for the five workloads that show up most often: native time-series collections, IoT telemetry, e-commerce catalogue + orders, social feed, and multi-tenant SaaS.

Series map: 00 Foundations → … → 16 Atlas → 17 Drivers & Case Studies (this post — final base).


Learning Outcome

  • Use the official Node driver + know when to layer Mongoose vs Prisma.
  • Switch idioms between pymongo / Motor for sync vs async Python.
  • Recognise driver patterns in Go, Rust, .NET.
  • Apply native time-series collections correctly.
  • Model IoT telemetry, e-commerce, social feed, multi-tenant SaaS end to end.

Chapter 1 — Node.js — official driver

1
2
npm install mongodb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { MongoClient, ObjectId } from 'mongodb';

const client = new MongoClient(process.env.MONGO_URI);
await client.connect();
const db = client.db('shop');

const orders = db.collection('orders');

await orders.insertOne({
_id: new ObjectId(),
user_id: 1,
total: 99.0,
status: 'paid',
at: new Date()
});

const recent = await orders
.find({ user_id: 1, status: 'paid' })
.sort({ at: -1 })
.limit(10)
.toArray();

await client.close();

Strengths:

  • Thin, no opinion.
  • TypeScript first-class.
  • Fastest driver in the JS ecosystem.

Combine with zod / valibot for runtime schema validation if you want types without a heavier ORM/ODM.


Chapter 2 — Node.js — Mongoose ODM

1
2
npm install mongoose
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import mongoose, { Schema, model } from 'mongoose';

await mongoose.connect(process.env.MONGO_URI);

const UserSchema = new Schema({
name: { type: String, required: true },
email: { type: String, unique: true, required: true, match: /@/ },
age: { type: Number, min: 0, max: 150 },
tags: [String],
created_at: { type: Date, default: Date.now }
});

UserSchema.methods.greet = function () { return `Hi, ${this.name}`; };
UserSchema.pre('save', function (next) { /* hook */ next(); });

const User = model('User', UserSchema);

const u = await User.create({ name: 'Ada', email: 'ada@x' });
u.greet();

Mongoose adds:

  • Schemas + types + validation at the app layer.
  • Pre/post hooks (lifecycle middleware).
  • Population (ref + populate — $lookup from the app side).
  • Virtuals + getters/setters.
  • Methods + statics.

Trade-off: extra abstraction, ~30 % slower than the raw driver, sometimes opaque. Many teams pick the official driver + Zod for new projects in 2026 — leaner.


Chapter 3 — Node.js — Prisma + MongoDB

Prisma (originally Postgres-only) has supported MongoDB since 2022. Schema-first ORM with TypeScript types generated for you.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// schema.prisma
datasource db {
provider = "mongodb"
url = env("DATABASE_URL")
}

model User {
id String @id @default(auto()) @map("_id") @db.ObjectId
name String
email String @unique
age Int?
createdAt DateTime @default(now())
}
1
2
3
4
5
6
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();

const u = await prisma.user.create({ data: { name: 'Ada', email: 'ada@x' } });
const all = await prisma.user.findMany({ where: { age: { gte: 18 } }, orderBy: { createdAt: 'desc' } });

Strengths: type-safe queries (compile-time errors on wrong fields), excellent DX.

Weaknesses (vs Postgres Prisma):

  • Relations on MongoDB use references-with-IDs (not native foreign keys). Joins via include are app-side.
  • Some Prisma features are SQL-only.

Choose Prisma if the rest of your stack uses Prisma + you value the TypeScript ergonomics. Choose the official driver if you want raw control or you write a lot of aggregation pipelines (Prisma's aggregation API is less expressive).


Chapter 4 — Python — pymongo + Motor

pymongo (synchronous)

1
2
pip install pymongo
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from pymongo import MongoClient
from bson import ObjectId

client = MongoClient(os.environ['MONGO_URI'])
db = client['shop']

db.orders.insert_one({
'_id': ObjectId(),
'user_id': 1,
'total': 99.0,
'status': 'paid',
'at': datetime.utcnow()
})

for doc in db.orders.find({'user_id': 1}).sort('at', -1).limit(10):
print(doc)

client.close()

Motor (async)

1
2
pip install motor
1
2
3
4
5
6
7
8
9
10
from motor.motor_asyncio import AsyncIOMotorClient

client = AsyncIOMotorClient(os.environ['MONGO_URI'])
db = client.shop

await db.orders.insert_one({...})

async for doc in db.orders.find({...}).sort('at', -1).limit(10):
print(doc)

ODMs

  • Beanie — async ODM on top of Motor + Pydantic. Excellent if you're on FastAPI + Pydantic.
  • MongoEngine — older, sync, less popular in 2026.
  • ODMantic — modern Pydantic-based.

For new FastAPI projects, Beanie is the modern choice. For scripts + ETL, raw pymongo.


Chapter 5 — Go

1
2
go get go.mongodb.org/mongo-driver/v2/mongo
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ctx := context.Background()
client, err := mongo.Connect(options.Client().ApplyURI(uri))
defer client.Disconnect(ctx)

orders := client.Database("shop").Collection("orders")

_, err = orders.InsertOne(ctx, bson.M{
"user_id": 1,
"total": 99.0,
"status": "paid",
"at": time.Now(),
})

cur, err := orders.Find(ctx, bson.M{"user_id": 1}, options.Find().SetSort(bson.M{"at": -1}).SetLimit(10))
for cur.Next(ctx) {
var doc bson.M
cur.Decode(&doc)
fmt.Println(doc)
}

Idiomatic Go: pass context.Context to every op; use struct tags for marshalling.

1
2
3
4
5
6
7
8
type Order struct {
ID primitive.ObjectID `bson:"_id,omitempty"`
UserID int `bson:"user_id"`
Total float64 `bson:"total"`
Status string `bson:"status"`
At time.Time `bson:"at"`
}

Chapter 6 — Rust

1
2
3
4
[dependencies]
mongodb = "3.0"
tokio = { version = "1", features = ["full"] }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use mongodb::{Client, bson::{doc, Document}};

let client = Client::with_uri_str(&uri).await?;
let orders = client.database("shop").collection::<Document>("orders");

orders.insert_one(doc! {
"user_id": 1,
"total": 99.0,
"status": "paid"
}).await?;

let mut cur = orders.find(doc! { "user_id": 1 }).await?;
while let Some(doc) = cur.try_next().await? {
println!("{:?}", doc);
}

Async via Tokio. For typed collections, derive Serialize + Deserialize from serde on your struct + collection::<MyType>(...).


Chapter 7 — .NET / C#

1
2
3
4
5
6
7
8
var client = new MongoClient(uri);
var orders = client.GetDatabase("shop").GetCollection<Order>("orders");

await orders.InsertOneAsync(new Order { UserId = 1, Total = 99, Status = "paid", At = DateTime.UtcNow });

var filter = Builders<Order>.Filter.Eq(o => o.UserId, 1) & Builders<Order>.Filter.Eq(o => o.Status, "paid");
var recent = await orders.Find(filter).SortByDescending(o => o.At).Limit(10).ToListAsync();

Builders<T>.Filter provides typed query construction. LINQ also supported for queries + aggregations.


Chapter 8 — Driver picking rules of thumb

Project Pick
TypeScript backend, type-safety priority Prisma OR official driver + Zod
Node.js with traditional ODM/hooks expectations Mongoose
FastAPI + Pydantic Beanie
Python scripts + ETL raw pymongo
Async Python service Motor (with or without Beanie)
Go service official driver + struct tags
Rust service official driver + serde
.NET / Unity official driver

All drivers use the same wire protocol. Pool, write/read concern, retryability options are identical across — Post 08 applies everywhere.


Chapter 9 — Case study A: time-series collections

For metrics, IoT telemetry, financial ticks: MongoDB 5.0+ ships native time-series collections that auto-bucket + columnar-compress data.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
db.createCollection("sensor_readings", {
timeseries: {
timeField: "ts",
metaField: "device_id", // groups buckets by device
granularity: "seconds" // seconds | minutes | hours
},
expireAfterSeconds: 60 * 60 * 24 * 90 // auto-delete after 90 days
})

db.sensor_readings.insertMany([
{ ts: new Date(), device_id: "d1", temp: 23.4, humid: 55 },
{ ts: new Date(), device_id: "d1", temp: 23.5, humid: 55 },
// ...
])

Inserts look like a normal collection. Internally documents are bucketed by metaField + time, compressed columnarly. Reads use the same query API, with optimisations for time ranges.

Pros:

  • 70-90 % storage reduction vs naive 1-doc-per-event.
  • Faster time-range queries.
  • Built-in TTL.
  • Works with $setWindowFields, $densify, $fill.

Limits:

  • No updates of individual events (delete + insert if needed).
  • No unique indexes on data fields (other than timeField + metaField).
  • Schema-on-insert is strict for the meta + time fields.

For high-volume telemetry (IoT, observability, finance), time-series collections beat hand-rolled bucket patterns (Post 07 Ch 5) in storage + performance + simplicity.


Chapter 10 — Case study B: IoT device telemetry

End-to-end model:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// devices — slow-changing dimension
db.devices.insertOne({
_id: "d1",
name: "kitchen-temp",
location: { lat: 51.5, lon: -0.1 },
installed_at: new Date(),
status: "active"
});

// telemetry — time-series collection (Chapter 9)
db.createCollection("telemetry", {
timeseries: { timeField: "ts", metaField: "device_id", granularity: "seconds" },
expireAfterSeconds: 60 * 60 * 24 * 30
});

// rollup collection — pre-aggregated minute / hour / day
db.createCollection("telemetry_5m", {
timeseries: { timeField: "bucket_start", metaField: "device_id", granularity: "minutes" },
expireAfterSeconds: 60 * 60 * 24 * 365
});

Ingest path: device → MQTT broker → ingest service → telemetry.insertMany([...]) in batches.

Rollup: scheduled aggregation every 5 minutes, $merge into telemetry_5m. Old raw data drops at TTL; rollups persist a year.

Real-time queries: vector dashboard reads telemetry_5m for the chart, drills into raw telemetry only when investigating an incident.

Alerting: change stream on telemetry triggers a function when temp > threshold.

This pattern (raw TS + pre-aggregated rollups + dashboards on rollups) is the canonical IoT architecture in 2026.


Chapter 11 — Case study C: e-commerce

Collections + relationships:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// products — small enough for catalogue queries
db.products.insertOne({
_id: "p1",
type: "shirt", // polymorphic discriminator
title: "Cotton Tee",
price: 19.99,
stock: 120,
attributes: [ // attribute pattern (Post 07)
{ k: "size", v: "M" },
{ k: "colour", v: "red" }
],
category_path: ["clothing", "tops", "tshirts"] // materialised path
});

// category tree — parent ref + ancestors hybrid
db.categories.insertOne({
_id: "tshirts",
name: "T-shirts",
parent: "tops",
ancestors: ["clothing", "tops"]
});

// users
db.users.insertOne({
_id: 1, name: "Ada", email: "ada@x",
addresses: [{ label: "home", line1: "...", city: "London" }] // 1:few embed
});

// carts — small, per-user, may be soft-deleted
db.carts.insertOne({
_id: 1, user_id: 1, items: [{ p: "p1", qty: 2 }],
updated_at: new Date()
});

// orders — immutable + extended reference for snapshots
db.orders.insertOne({
_id: ObjectId(),
user_id: 1,
user_email_at_order: "ada@x", // extended ref — survives email change
items: [
{ p: "p1", title_at_order: "Cotton Tee", price_at_order: 19.99, qty: 2 }
],
total: 39.98,
status: "paid",
at: new Date()
});

// reviews — separate collection, queried both ways
db.reviews.insertOne({
product_id: "p1", user_id: 1, rating: 5, body: "..."
});

// audit_log — TTL'd
db.createCollection("audit_log", { capped: false });
db.audit_log.createIndex({ at: 1 }, { expireAfterSeconds: 60 * 60 * 24 * 90 });

Hot paths:

  • Catalogue search → Atlas Search index on products.
  • Personalised recommendations → Atlas Vector Search over product embeddings.
  • Cart updates → single-doc atomic $inc on stock + push to cart.
  • Order placement → single-doc transaction (Post 09) covering stock decrement + order insert.
  • Reporting → $merge materialised views (daily_revenue, top_products).

Chapter 12 — Case study D: social feed

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// users — basic profile
db.users.insertOne({ _id: 1, handle: "ada", display_name: "Ada", followers_count: 0, following_count: 0 });

// follows — separate edge collection (many:many)
db.follows.createIndex({ follower: 1, followee: 1 }, { unique: true });
db.follows.createIndex({ followee: 1, since: -1 });
db.follows.insertOne({ follower: 2, followee: 1, since: new Date() });

// posts — immutable + indexed by author + time
db.posts.createIndex({ author: 1, at: -1 });
db.posts.insertOne({ _id: ObjectId(), author: 1, text: "hello", at: new Date(), likes: 0 });

// likes — separate edge
db.likes.createIndex({ user: 1, post: 1 }, { unique: true });

// timeline approaches:

// (A) Fan-out on READ — query at read time
function timeline(userId, before) {
const following = await db.follows.find({ follower: userId }).map(d => d.followee).toArray();
return db.posts.find({
author: { $in: following },
at: { $lt: before }
}).sort({ at: -1 }).limit(20).toArray();
}

// (B) Fan-out on WRITE — push to each follower's inbox at post time
// timeline_inbox: { user_id, post_id, author, at }
// On post: insert one row per follower
// Read: query inbox where user_id = me, sort at desc

Trade-offs:

  • Read fan-out — cheap writes, expensive reads (especially for users following thousands). Good for small networks.
  • Write fan-out — cheap reads, expensive writes (a celebrity's post inserts millions of rows). Good for read-heavy feeds.
  • Hybrid — fan-out on write for normal users; fan-out on read for celebrities (mark with is_high_fanout: true, query separately).

This is the Twitter/Mastodon problem. There's no one-size-fits-all answer.

Social feed timelines: fan-out on read versus fan-out on write, and the hybrid


Chapter 13 — Case study E: multi-tenant SaaS

Three patterns. Pick by tenant count + isolation needs + per-tenant size.

Pattern 1 — Shared collection, tenant field

1
2
3
4
5
// users collection holds users for all tenants
{ _id: ObjectId, tenant_id: "acme", name: "..." }

db.users.createIndex({ tenant_id: 1, _id: 1 })

Every query includes tenant_id: X. Compound indexes start with tenant_id.

Pros: minimal overhead; easy to add a tenant; queries simple.
Cons: noisy-neighbour effects; per-tenant operations (drop) are app-side; less isolation.

Best for: thousands of small tenants.

Pattern 2 — Collection per tenant

1
2
3
db['users_acme'].find({})
db['users_globex'].find({})

Pros: per-tenant indexes + backup + drop possible.
Cons: many collections per database (limit: a few thousand before metadata strain); harder to query across tenants.

Best for: dozens of medium tenants needing some isolation.

Pattern 3 — Database per tenant

1
2
3
client.db('shop_acme').collection('users')
client.db('shop_globex').collection('users')

Pros: full isolation; per-tenant backup, restore, IAM; easy to off-board.
Cons: connection per DB; metadata overhead at hundreds of DBs; harder cross-tenant analytics.

Best for: a few large tenants; regulatory isolation; on-premise hosting per customer.

Sharding tie-in

In Pattern 1, sharding the collection on { tenant_id: 1, _id: 1 } keeps each tenant's data on one shard (with tagSets you can pin specific tenants to specific shards or regions).


Chapter 14 — Production-ready service skeleton (Node TypeScript + official driver)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import { MongoClient, ObjectId } from 'mongodb';
import { z } from 'zod';

// schema validation at app boundary
const Order = z.object({
_id: z.instanceof(ObjectId).optional(),
user_id: z.number().int().positive(),
total: z.number().positive(),
status: z.enum(['pending', 'paid', 'shipped', 'cancelled']),
at: z.date()
});
type Order = z.infer<typeof Order>;

const client = new MongoClient(process.env.MONGO_URI!, {
maxPoolSize: 50,
minPoolSize: 5,
appName: 'orders-svc',
writeConcern: { w: 'majority', j: true, wtimeout: 5000 }
});
await client.connect();
const db = client.db('shop');
const orders = db.collection<Order>('orders');

export async function createOrder(input: Omit<Order, '_id'>) {
const parsed = Order.omit({ _id: true }).parse(input);
const r = await orders.insertOne({ ...parsed, at: new Date() });
return { _id: r.insertedId, ...parsed };
}

export async function findRecentByUser(user_id: number, limit = 20) {
return orders
.find({ user_id, status: 'paid' })
.sort({ at: -1 })
.limit(limit)
.maxTimeMS(500)
.toArray();
}

// graceful shutdown
process.on('SIGTERM', async () => { await client.close(); process.exit(0); });

Recipe summary:

  • One client per process; explicit pool + concerns + appName.
  • Validate at the app boundary (Zod) — runtime + types.
  • .maxTimeMS() on user-facing queries.
  • Graceful shutdown.

Drop into a Fastify / Express / Hono handler — done.


Chapter 15 — Common pitfalls in driver code

Pitfall Fix
Connecting per request one client per process
Mongoose lean() not used reads return heavy documents; use .lean() for plain JS objects
Forgetting await on .toArray() Promise leaks; lint with no-floating-promises
ObjectId vs string mismatch in filter careful: new ObjectId(str) when querying
No appName tag hard to debug which service issued a query
BSON types lost in JSON EJSON.stringify for safe serialisation
Missing index in dev → slow query in prod run .explain in CI
Pool stress under bursty workload add waitQueueTimeoutMS; rightsize pool

Practice Exercises

Exercise 1 — Pick a driver

For the shop application throughout the series, pick a driver + ODM in your preferred language. Build createOrder, findRecent, cancelOrder.

Exercise 2 — Time-series collection

Create a sensor_readings time-series collection. Bulk-insert 100k synthetic readings across 10 devices over 1 hour. Query last-hour avg per device.

Exercise 3 — IoT rollup

Build a scheduled aggregation that rolls sensor_readings into a 5-minute summary collection via $merge. Confirm subsequent runs only touch new buckets.

Exercise 4 — E-commerce checkout

Implement placeOrder(userId, cartId) that atomically decrements stock for each item + inserts the order. Test concurrent runs for the same user shouldn't double-charge.

Exercise 5 — Social feed

For the social model, implement both read-fan-out and write-fan-out timelines. Benchmark each at 1k follows, 100k follows.

Exercise 6 — Multi-tenant

Implement all three multi-tenant patterns for a tasks collection. Discuss when each is right for your team.


Cheat-sheet

What How
Node official npm install mongodb; one MongoClient per process
Node + types official driver + Zod (or Prisma)
Node + ODM Mongoose (rich)
Python sync pymongo
Python async Motor (+ Beanie for FastAPI)
Go official driver + struct tags
Rust official driver + serde
.NET official driver + Builders<T>
Time-series db.createCollection(name, { timeseries: { timeField, metaField, granularity } })
TTL on TS expireAfterSeconds in createCollection
IoT pattern TS → rollups → dashboards
E-commerce catalogue / category tree / orders (extended ref) / reviews
Social timeline read fan-out (small) / write fan-out (read-heavy) / hybrid
Multi-tenant shared collection (small tenants) / collection-per-tenant (medium) / DB-per-tenant (large)
Sharded multi-tenant shard key starts with tenant_id

Course base complete

You've covered:

  • Foundations + document model.
  • Full CRUD + query operators.
  • Indexes + query optimisation.
  • Aggregation basics + advanced (windows, facets, graph, merge).
  • Schema design patterns.
  • Connection pool + concurrency + write/read concern.
  • Multi-document transactions.
  • Replica sets + sharding.
  • Change streams (CDC).
  • Security (auth, RBAC, TLS, CSFLE, Queryable Encryption).
  • Backup + ops + PITR + restore drills.
  • Performance deep (WiredTiger, working set, hot keys, GridFS).
  • Atlas Search + Vector Search + Triggers + Stream Processing.
  • Drivers + modelling case studies.

That's the working knowledge of a senior MongoDB engineer. Future extensions slot in:

Topic Why
MongoDB + Kubernetes operators (mongodb-kubernetes-operator) self-hosted on K8s
Migration patterns (SQL → Mongo) concrete walkthroughs
Multi-cloud Atlas regions, latency, cost
Mongo + LangChain / LlamaIndex RAG framework integrations
Performance benchmarks vs Postgres / DynamoDB / Cassandra when each wins
GeoSpatial deep dive maps, navigation, geofencing
Time-series at extreme scale columnar engines, ClickHouse comparison

References

Note: this post — and the base course — is in progress. Planned additions: end-to-end RAG sample app; Mongoose-vs-Prisma migration walkthrough; multi-tenant case study at 10,000 tenants; benchmarks page per workload pattern.