Document Modeling: Embedding, References, and Schema Evolution
A document database stores records as documents rather than normalized rows spread across many tables. The main modeling question is not "can this be JSON?" but:
Which data is read, written, and versioned together?
A good document boundary follows application operations.
A bookstore example
A product document might look like:
{
"_id": "book-101",
"title": "Database Systems",
"price_cents": 4990,
"publisher": {
"name": "Example Press",
"country": "DE"
},
"tags": ["database", "systems"],
"dimensions": {
"pages": 720,
"format": "hardcover"
}
}
The document groups fields that are commonly loaded together.
A relational representation might use several tables:
products
publishers
product_tags
product_dimensions
A document representation can retrieve one product without a join.
That is useful only if the embedded data has compatible ownership and update semantics.
Document boundary
Think of one document as an aggregate boundary:
product
βββ identity
βββ descriptive fields
βββ dimensions
βββ tags
βββ embedded publisher snapshot?
Ask:
- Is the nested data owned by the parent?
- Is it usually read with the parent?
- Does it change at roughly the same time?
- Can it grow without bound?
- Does another part of the system need independent access to it?
These questions decide whether to embed or reference.
Embedding
Embedding stores related data inside the parent document.
Example:
{
"_id": "order-1001",
"customer_id": "customer-42",
"placed_at": "2026-10-01T12:00:00Z",
"items": [
{
"product_id": "book-101",
"title_at_purchase": "Database Systems",
"unit_price_cents": 4990,
"quantity": 1
},
{
"product_id": "book-205",
"title_at_purchase": "Distributed Data",
"unit_price_cents": 3500,
"quantity": 2
}
]
}
The line items belong to the order.
The order is normally read as one unit.
Embedding is a natural fit.
Why historical snapshots are different from duplication bugs
The order stores:
title_at_purchase
unit_price_cents
even though the current product record already has a title and price.
That duplication is intentional because the values represent historical facts.
If the product price changes tomorrow, yesterday's order should not change.
current product price
β
βββ may change
β
order purchase price
βββ historical fact
Document duplication is not automatically denormalization debt.
The meaning of the copied field matters.
Reference instead of embed
Suppose the same publisher record is used by 100,000 books and its legal name changes.
Embedding the current publisher name in every product creates a large fan-out update.
A reference may be better:
{
"_id": "book-101",
"title": "Database Systems",
"publisher_id": "publisher-7"
}
Separate document:
{
"_id": "publisher-7",
"name": "Example Press GmbH",
"country": "DE"
}
Now the publisher can change independently.
Embed or reference decision
A useful decision table:
| Question | Embed tends to fit | Reference tends to fit |
|---|---|---|
| Read together? | usually | not always |
| Updated together? | usually | independently |
| Child has independent identity? | weak/no | strong |
| Child shared by many parents? | rarely | often |
| Child collection grows without bound? | no | possibly |
| Need independent queries? | rarely | often |
This is not a law. It is a way to reason about the access pattern.
One-to-few
Embedding works especially well for bounded "one-to-few" relationships.
Example:
customer
βββ shipping_addresses
βββ home
βββ office
The address count is naturally small.
One-to-many with a bound
A product may embed:
last 10 reviews
while all reviews live separately.
This hybrid design can optimize a common read:
product page
βββ needs recent reviews immediately
The full review archive remains independently queryable.
Unbounded arrays are dangerous
Bad model:
{
"_id": "customer-42",
"events": [
"... millions more over years ..."
]
}
Problems include:
- document growth,
- expensive rewrites,
- large network payloads,
- update contention,
- product-specific document-size limits.
Instead, use independent event documents or buckets.
Bucket pattern
Group time-series/event records into bounded buckets.
Example key:
customer-42 / 2026-10
Document:
{
"_id": "customer-42:2026-10",
"customer_id": "customer-42",
"month": "2026-10",
"events": [
{"ts": "...", "type": "login"},
{"ts": "...", "type": "purchase"}
]
}
Now each bucket has a planned upper size.
Atomic document updates
Document databases often provide atomicity at the document level.
That makes document boundary a correctness decision.
Suppose an inventory document stores:
{
"_id": "sku-101",
"available": 7,
"reserved": 3
}
Updating both fields in one document can preserve an invariant more simply than coordinating several independent records.
But do not assume every product/database offers the same transaction scope. Check the exact engine.
Multi-document transactions
Modern document databases may support multi-document transactions.
That does not make document design irrelevant.
A transaction spanning many documents can still increase:
- coordination,
- lock/transaction duration,
- failure handling,
- latency.
Prefer natural aggregate boundaries first.
Use multi-document transactions when the business rule genuinely spans them.
Denormalization
Document models often duplicate derived/read-optimized data.
Example:
orders
βββ customer_id
βββ customer_display_name_snapshot
Possible strategies:
Historical snapshot
Value is intentionally frozen.
No propagation required.
Current projection
Value should track another source.
Requires update propagation.
Rebuildable derived view
Value can be regenerated from source data.
The strategy must be explicit.
Source of truth
When values appear in several places, define an authority.
Example:
customers.name = authoritative current display name
orders.customer_name_at_purchase
= immutable historical snapshot
search_index.customer_name
= rebuildable projection
Without this distinction, teams cannot tell which copy should win after a conflict.
Schema flexibility does not mean no schema
Documents still have expectations.
Example:
orders
βββ _id
βββ customer_id
βββ placed_at
βββ items[]
βββ product_id
βββ quantity
βββ unit_price_cents
Applications, indexes, validators, and analytics all depend on these fields.
The schema may be:
- application-enforced,
- database-validated,
- versioned,
- partly optional.
It still exists.
Validation
MongoDB, for example, can apply collection validation rules.
A simplified validation shape:
{
$jsonSchema: {
bsonType: "object",
required: ["customer_id", "placed_at", "items"]
}
}
Validation reduces accidental malformed documents.
It does not replace business validation.
Missing versus null
Document systems often distinguish:
field missing
from:
{"field": null}
These may mean different things.
For example:
middle_name missing -> not collected
middle_name null -> explicitly no value
The repository already contains a MongoDB demonstration:
scripts/mongo/null_vs_missing_fields.py
Use it to inspect how those cases behave in queries.
Replacement versus partial update
Document databases often support both:
- replacing the whole document,
- updating selected fields.
Those operations have different risks.
Repository demo:
scripts/mongo/replace_one_vs_update_one.py
A replacement can accidentally remove fields the caller did not include.
A partial update can preserve unrelated fields.
Choose based on ownership semantics.
Schema evolution
Suppose version 1 stores:
{
"name": "Alice"
}
Version 2 wants:
{
"first_name": "Alice",
"last_name": null
}
A rolling migration may temporarily need to read both shapes.
old app -> old shape
new app -> understands old + new
migration -> rewrites records
new app -> writes new only
cleanup -> remove old compatibility
This is an expand-and-contract pattern.
Version field
Some systems include an explicit schema version:
{
"_id": "customer-42",
"schema_version": 3,
...
}
This can help application migration logic.
Do not add version fields mechanically. Use them when records genuinely need version-dependent interpretation.
Lazy migration
Instead of rewriting every document at deployment time, the application can upgrade a document when it is read or changed.
Advantages:
- lower migration burst,
- easier gradual rollout.
Risks:
- old shapes persist for a long time,
- reads become more complex,
- rare records may never migrate.
Eager migration
Rewrite all matching documents in a controlled job.
Advantages:
- consistent final state,
- simpler application after completion.
Risks:
- high write volume,
- large replication load,
- long-running backfill,
- rollback complexity.
Measure on production-scale data.
Indexes follow access patterns
Suppose the application loads:
all open orders for customer 42
ordered newest first
The index should reflect the filter/sort pattern.
In MongoDB-style notation:
{ customer_id: 1, status: 1, placed_at: -1 }
Do not create indexes just because fields exist.
Each index costs:
- disk,
- memory,
- write amplification.
Array indexes
An array field can create multiple index entries per document.
Example:
{
"tags": ["database", "distributed", "sql"]
}
Indexing tags supports membership queries.
But large arrays increase index size and write cost.
Compound index order
Index field order matters.
A typical reasoning sequence is:
- equality filters,
- range filters,
- sort requirements.
Always verify with the engine's query planner rather than applying a formula blindly.
Projection
Return only needed fields.
Instead of fetching a full product document containing:
- large description,
- images metadata,
- supplier history,
- reviews,
a listing page may need only:
_id
title
price
thumbnail
Projection reduces network and deserialization work.
Pagination
Offset pagination can become expensive for deep pages.
Keyset-style pagination can use a stable sort key:
placed_at DESC, _id DESC
Then request records after the last seen pair.
The exact syntax depends on the database.
Duplicate data and write amplification
Suppose product title appears in:
- product document,
- open cart items,
- search index,
- recommendation projection.
Changing the title may require several updates.
A document design should list all derived copies and define:
- update mechanism,
- tolerated staleness,
- rebuild procedure.
Event-driven propagation
One pattern:
product update
β
βΌ
database commit
β
βΌ
event / CDC
β
ββββΊ search projection
ββββΊ recommendation projection
Derived copies become eventually consistent.
That may be acceptable for search but not for payment totals.
Optimistic concurrency
A version field can detect lost updates.
Document:
{
"_id": "product-101",
"version": 7,
"price_cents": 4990
}
Update condition:
_id = product-101
AND version = 7
Update:
price = 5190
version = 8
If zero documents match, another writer changed the record first.
Idempotent writes
Retries happen in distributed systems.
A create operation can include a business idempotency key:
checkout_request_id = 6f...
The database/application can reject duplicate processing.
Do not rely on "the client only sends once."
Sharding implications
Document key design matters when the database is sharded.
A shard key affects:
- distribution,
- query routing,
- hotspots,
- resharding complexity.
Bad keys can concentrate writes.
Example:
created_at increasing forever
may route all newest writes to one range/shard depending on the engine.
Choose from actual workload and engine behavior.
Document modeling workflow
For each feature:
- List the exact reads.
- List the exact writes.
- Identify data that changes together.
- Identify unbounded relationships.
- Mark historical snapshots versus current projections.
- Decide embed versus reference.
- Define validation.
- Design required indexes.
- Define schema-evolution strategy.
- Test concurrency and retry behavior.
- Estimate maximum document size/growth.
- Check sharding implications if distributed.
Common mistakes
"Documents remove joins, so embed everything"
Unbounded/shared data becomes painful.
"Schema-less means migration-less"
Applications still depend on structure.
Current values copied as if they were historical
Updates become inconsistent.
Unbounded arrays
Documents grow indefinitely.
Whole-document replacement from partial input
Fields disappear unexpectedly.
Index every field
Write and memory cost grows.
No source-of-truth definition
Conflicting copies cannot be reconciled.
When a relational model may be simpler
Prefer a relational design when:
- many entities change independently,
- joins are central,
- constraints across records matter,
- ad-hoc query patterns evolve frequently,
- transactional rules span several entities.
A document database is not a default replacement for normalized relational models.