135: MongoDB Data Modeling — Access Patterns, Embedding, Referencing, Schema Validation, and Anti-Patterns
Learning objectives
You will learn to:
- design MongoDB schemas from access patterns;
- choose embedding versus referencing;
- model one-to-one, one-to-few, one-to-many, and many-to-many relationships;
- understand document growth and bounded arrays;
- apply Extended Reference, Subset, Bucket, and Computed-style patterns conceptually;
- understand polymorphic documents;
- use schema validation;
- plan schema evolution;
- avoid joins/transactions caused by poor document boundaries;
- understand multi-tenant modeling;
- reason about denormalization and consistency.
Start from access patterns
Do not ask:
What are my entities?
first.
Ask:
What does the application read and write together?
For an order page, common read:
order header customer snapshot line items totals shipping address
Embedding those can make one read return the order aggregate.
Relational instinct versus document model
Relational normalized:
orders order_items addresses customers
Mongo can embed:
{
_id: ObjectId(...),
customer: {
id: ObjectId(...),
name: "Maya",
email: "..."
},
items: [
{
productId: ObjectId(...),
name: "Notebook",
quantity: 2,
pricePaise: 12000
}
],
shippingAddress: {...},
totals: {...}
}
This intentionally duplicates customer/product snapshot data because historical order should preserve what was purchased.
Denormalization can be correct.
Embedding advantages
- one read;
- atomic update within document;
- natural aggregate;
- fewer joins;
- locality.
Embedding disadvantages
- duplication;
- document growth;
- large updates;
- repeated data synchronization if canonical field must change everywhere;
- document size limits.
Referencing advantages
- independent lifecycle;
- avoids huge duplication;
- avoids unbounded arrays;
- many relationships.
Disadvantages:
- additional query;
$lookup;- application joins;
- multi-document consistency.
One-to-one
If data always read together and same lifecycle:
{
profile: {
displayName: "...",
timezone: "Asia/Tokyo"
}
}
Embed.
If independently secured/huge/lifecycle different, separate collection may fit.
One-to-few
Order line items usually bounded.
Embed:
items: [...]
One-to-many
Blog author with millions of posts.
Do not:
{
userId,
postIds: [
// millions forever
]
}
Instead posts reference author:
{ _id, authorId, ... }
Query posts by indexed authorId.
Many-to-many
Students ↔ courses.
Options:
- references in one side;
- references both sides;
- enrollment collection.
If relationship itself has data:
{ studentId, courseId, enrolledAt, status, grade }
an enrollment collection is natural.
Boundedness test
Before embedding array, ask:
maximum expected count? upper hard bound? can it grow forever? how often updated? how often entire array read?
If no meaningful bound, avoid unbounded embedding.
Document growth
Repeatedly pushing into huge document can cause:
- larger reads/writes;
- index expansion;
- document size limit;
- contention on one document.
Time-series/event-style data often needs bucket/separate documents.
Duplication is a trade-off
Product name duplicated in orders.
Should historical order name update if product renamed?
Often no.
Then duplicate snapshot is domain-correct.
Customer current phone duplicated in account dashboard where should always be current?
Maybe reference/query canonical customer instead.
Ask whether duplicated value is:
snapshot
or:
cached canonical field
The second needs synchronization strategy.
Extended Reference pattern
Keep reference plus frequently needed small fields:
{
customer: {
id: ObjectId(...),
displayName: "Maya"
}
}
Avoid extra lookup for display.
Canonical customer record remains source for mutable profile.
If displayName changes, decide whether old order should change.
Subset pattern
A main document may embed only frequently needed subset of related data.
Example product:
{
recentReviews: [
// last 3
]
}
Full reviews separate collection.
This improves common read but requires update logic.
Computed pattern
Store precomputed value:
{
reviewCount: 238,
averageRating: Decimal128("4.6")
}
instead of aggregating all reviews every request.
Now writes must maintain computed values or async rebuild.
Trade read performance for write complexity.
Bucket pattern
Group many small records into bounded buckets.
Example sensor/events by hour/day.
{
deviceId,
bucketStart,
measurements: [...]
}
Useful when individual event documents would be enormous in count and read in groups.
MongoDB time-series collections provide specialized behavior for time-series use cases; prefer them where appropriate.
Polymorphic collections
Example notifications:
{
type: "email",
to: "...",
subject: "..."
}
{
type: "sms",
to: "...",
message: "..."
}
Shared common fields can coexist.
Use discriminator/type field and validator rules.
Avoid collection becoming unrelated junk drawer.
Schema validation
Use $jsonSchema.
Example:
db.createCollection("orders", {
validator: {
$jsonSchema: {
bsonType: "object",
required: [
"tenantId",
"status",
"items",
"createdAt"
],
properties: {
tenantId: {
bsonType: "objectId"
},
status: {
enum: [
"draft",
"placed",
"completed",
"cancelled"
]
},
items: {
bsonType: "array",
minItems: 1
},
createdAt: {
bsonType: "date"
}
}
}
}
})
Database validation protects all writers.
Application schema can give richer user errors.
Validation levels/actions
MongoDB validation settings can control how validation applies and whether invalid writes error/warn.
Use strict production policy unless migration scenario intentionally needs staged rollout.
Schema migration
MongoDB flexible schema means migrations still exist.
Example v1:
{
name: "Maya"
}
v2:
{
profile: {
name: "Maya"
},
schemaVersion: 2
}
Strategies:
Eager migration
Script updates all documents.
Lazy read migration
Application reads old shape and upgrades on write/read.
Dual-read/write transition
Temporarily support both.
Do not leave indefinite ambiguous schema if code becomes impossible to reason about.
Schema version field
{
schemaVersion: 3
}
Useful for complex long-lived polymorphic migrations.
Not required for every simple collection.
Multi-tenant modeling
Common shared collection:
{
tenantId: ObjectId(...),
...
}
Every query includes tenant.
Indexes usually start with tenant where query patterns require:
{ tenantId: 1, createdAt: -1 }
Do not trust tenantId from client body.
Derive server auth context.
Database per tenant
Alternative for strong isolation/small tenant count.
Trade-offs:
- many databases;
- migrations;
- connections;
- operations.
Collection per tenant is often operationally painful.
Choose tenant strategy intentionally.
Hot documents
One document updated extremely frequently becomes contention hotspot.
Example:
{
globalCounter: ...
}
at huge write rate.
Consider sharded counters/bucketing/event design.
MongoDB can handle atomic updates, but one document has finite write throughput.
Arrays and indexes
Multikey indexes index array elements.
A document with huge array can produce many index entries.
Compound multikey rules/limitations matter.
Do not create giant arrays and expect indexes to make them free.
$lookup as design signal
MongoDB supports join-like $lookup.
It is useful.
But if every primary query performs many complex lookups across normalized collections, revisit whether MongoDB document model is being used effectively.
Do not ban $lookup; treat frequent expensive joins as modeling signal.
Transactions as design signal
Multi-document transactions are valid.
But if basic aggregate update always requires 8 documents, perhaps document boundary is too normalized.
Again, not a rule—just a design question.
Data duplication consistency
Strategies:
Synchronous
Update canonical and duplicates in transaction.
Eventual
Change canonical; background worker updates projections.
Snapshot
Do not update historical duplicate.
Document which semantics apply.
Deletion
If referenced document deleted, MongoDB does not automatically enforce foreign-key cascade.
Application must define:
- restrict delete;
- soft delete;
- cascade;
- orphan allowed;
- cleanup job.
Do not assume referential integrity exists automatically.
Soft delete
{
deletedAt: ISODate(...)
}
Benefits:
- recovery/audit.
Costs:
- every query must exclude;
- unique indexes need partial strategy;
- data grows;
- accidental leakage.
Use if domain needs it, not by default.
Audit history
Do not grow:
history: [...]
forever inside record.
Use audit collection/event store.
Access-pattern worksheet
For each collection:
Top reads: Top writes: Filter fields: Sort fields: Expected document size: Expected array bounds: Atomic update boundary: Deletion behavior: Tenant scope: Retention: Indexes:
Design index/schema together.
Anti-patterns
- one collection per tenant/user;
- huge unbounded arrays;
- arbitrary key/value mega-document;
- relational schema copied 1:1 without reason;
- embed canonical mutable object everywhere with no sync plan;
- reference everything because SQL did;
- transaction for every basic write;
- no tenant field/index plan;
- no deletion/schema evolution strategy.
Exercises
- Model order aggregate.
- Model user + millions of posts.
- Model student-course enrollment.
- Choose snapshot versus canonical duplication.
- Design bounded recent reviews subset.
- Design audit history collection.
- Add tenant scoping.
- Write collection validator.
- Plan v1→v2 schema migration.
- Review a relational schema and intentionally remodel for Mongo access patterns.
Mastery checklist
Explain:
- access-pattern-first modeling;
- embed/reference;
- bounded arrays;
- denormalization;
- subset/computed/bucket patterns;
- polymorphism;
- validation;
- migration;
- multi-tenancy;
$lookup/transaction design signals;- deletion and soft-delete trade-offs.
