Engineering Atlas › System Design Fundamentals

Storing flexible records in document databases

14 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

Documents and aggregates

A relational row is flat, so anything one-to-many (an order's line items) lives in another table joined by key. A document keeps that shape: one order holds the customer snapshot, the address, an array of line items and the payment status, and reading it is one lookup by _id.

Martin Fowler calls this aggregate orientation. The SQL vs NoSQL lesson compares the models by access pattern; here we go inside the engine. The design rule: draw document boundaries around what is read and changed together, and around what must change atomically.

Embedding versus referencing

Embed when the child belongs to one parent, is read with it, and is bounded: line items in an order. Reference (store the other document's _id) when the child is shared, large, independently changing or unbounded: the product a line item points at, the comments on a viral post. The extended reference sits between: embed the few fields you display (product name and price at purchase) and keep the _id. That copy is denormalized data (see Denormalization), fine for a price frozen at purchase, dangerous for a customer's current email.

Flexible schema is still a schema

Collections accept documents of any shape, so two versions of an entity can coexist during a migration; readers check a schemaVersion field and upgrade documents lazily on write. MongoDB can enforce a JSON Schema validator per collection, and teams that skip it discover years later that price is a string in 3% of documents. The default strict level checks every insert and update but says nothing about documents written before the validator existed; MongoDB 9.0 (September 2026) added a constraint level that guarantees every document in the collection satisfies the rules (MongoDB docs).

Querying inside documents

Filters name fields by path: {"customer.city": "Lyon"} descends into an embedded object. Arrays are where the model differs most from SQL. {"tags": "gift"} matches a document whose tags array contains "gift"; {"items.sku": "B7"} walks every element of items. Multiple operators on one array path, such as {"items.qty": {"$gt": 5, "$lt": 3}}, are each satisfied by some element, not necessarily the same one, so an order with quantities 2 and 9 matches. To require one element to satisfy all conditions you need $elemMatch. The Practice section has you implement exactly this.

Atomicity, transactions and scale

A write to one document is atomic and isolated, nested arrays included (MongoDB docs). That is the multi-step consistency problem shape solved by layout instead of locking, so good schemas keep invariants (an order's total equals the sum of its items) inside one document. MongoDB has multi-document ACID transactions (since 4.0 for replica sets, 4.2 for sharded clusters), but they cost round trips, abort after 60 seconds by default (MongoDB docs), and often signal a boundary drawn in the wrong place. Since 9.0 the server also rejects new transactions once 10,000 are open at the same time by default (MongoDB 9.0 release notes).

To scale out, a collection is sharded on a field such as customerId. Queries that include the shard key go to one shard; queries without it are scatter-gather. The same model appears in Postgres as a jsonb column with a GIN index, and in Firestore and Elasticsearch.

Down the stack

Layer 1The contract

A single-document write is atomic: all of its field changes are visible or none are. Durability is tunable per operation. With write concern w: "majority" (the default since MongoDB 5.0) the server acknowledges only once a majority of replica set members have the write, so it survives a primary failover. One exception: with arbiters, if data-bearing voting members do not outnumber the voting majority (two data nodes plus an arbiter, say), the default drops to w: 1 (MongoDB docs); with w: 1 a failover can roll it back. Read concern local may return data a failover later undoes, majority returns only majority-acknowledged data, and linearizable also confirms the primary is still primary. Nothing is promised across documents unless you open a transaction.

Layer 2The mechanism

On the wire and on disk, MongoDB documents are BSON: a 4-byte little-endian total length, a sequence of elements, then a 0x00 terminator. Each element is a 1-byte type tag, the field name as a null-terminated string, and the value. Field names repeat in every document, which is the storage tax of flexible schema. BSON documents are capped at 16 MiB and 100 levels of nesting (MongoDB limits).

WiredTiger keeps each collection in a B-tree keyed by an internal 64-bit RecordId; the _id index and secondary indexes are separate B-trees mapping values to RecordId. An index on an array field is multikey: one entry per element, so 50 tags make 50 entries, and a compound index may not cover two array fields of one document ("parallel arrays", which would need a cross product).

Replication streams an oplog, a capped collection of idempotent operations (MongoDB docs). An $inc from the client is logged as a $set of the resulting value, so replaying an entry twice is harmless. Elections follow a Raft-derived protocol. Sharded clusters split each collection into chunk ranges of the shard key (128 MB by default, MongoDB docs); a balancer moves chunks between shards, and mongos routers use a cached routing table to send each query to one shard or all of them.

Layer 3The machine

WiredTiger's cache defaults to the larger of 50% of (RAM minus 1 GB) and 256 MB, and holds pages uncompressed; on disk, collection blocks are Snappy-compressed by default (zstd for time series collections) and indexes use prefix compression (MongoDB docs), so repeated field names shrink on disk but cost full size in cache. Writes go to a journal (a write-ahead log) that is flushed with fsync every 100 ms by default, or immediately when a write asks for j: true (MongoDB docs). Every 60 seconds a checkpoint writes a consistent snapshot of the B-trees to new blocks, so recovery replays only the journal since the last checkpoint.

A small change to a large document is not free. In memory WiredTiger can keep a small delta, but when the page is written out the whole new document goes into the new page image, so updating one counter in a 2 MB document rewrites 2 MB on disk, and every read of it pulls megabytes through the cache.

Why it's built this way

  1. Applications think in objects and joins cost random I/O, so the engine stores the object as one contiguous record.
  2. Entities change shape over time, so each document carries its own field names; that costs bytes, so blocks are compressed.
  3. Contiguous records make single-record atomicity cheap, so that is the promise and cross-record transactions are opt-in.
  4. Queries into arrays must be fast, so indexes hold one entry per element, which makes huge arrays expensive to write.
  5. One machine runs out of disk and write throughput, so collections split by shard key, and a query's cost depends on naming it.

Diagram

flowchart LR
  App["App: find({customerId: 42})"] --> R["mongos router"]
  R -->|"shard key in filter"| S2["Shard 2 primary"]
  R -.->|"no shard key: all shards"| S1["Shard 1 primary"]
  S2 --> IX["Index B-tree: customerId to RecordId"]
  IX --> CB["Collection B-tree: RecordId to BSON"]
  S2 --> J["Journal, fsync every 100 ms"]
  S2 --> OP["Oplog to secondaries"]

Worked example

A marketplace stores 200 million orders. An order has 4 line items of about 150 bytes of BSON each plus a 600-byte envelope (customer snapshot, address, status), roughly 1.2 KB, or 240 GB in total. Field names are about 25% of that: 30 fields at about 10 bytes each. Snappy brings it to around 100 GB on disk; the cache still holds documents uncompressed.

Loading one order by _id is two B-tree descents (index, then collection) with cached upper levels: one or two disk reads at worst. The relational version joins four tables.

Now product teams ask for "all orders containing SKU B7 in the last week". An index on {"items.sku": 1, "createdAt": -1} is multikey: 800 million entries for 200 million orders. At about 40 bytes per entry that is 32 GB of index, and every new order writes 4 entries. Sharded on customerId, this query cannot target one shard, so with 8 shards each query costs 8 index scans plus a merge in mongos. If it runs 500 times a second, that is 4,000 shard-level queries a second; a separate read model keyed by SKU, fed from a change stream, may be cheaper.

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Embed childrenOne read, atomic updates, data localityDuplication, document growth, 16 MiB capChild is owned, bounded and read with the parent
Reference by _idNo duplication, independent growthExtra round trips or $lookup, no cross-document atomicityChild is shared, large or unbounded
Extended referenceFast display with a stable snapshotCopies drift if the source changesValues that should be frozen at write time
Bucket pattern (fixed-size child documents)Bounded documents, fewer index entriesMore complex reads and writesHigh-volume child events such as readings or messages
Relational table with jsonbJoins, constraints and flexible fields togetherWeaker horizontal scaling, GIN index write costMostly relational data with some variable attributes

Pitfalls in production

Leadership lens

Check yourself

  1. Why is {"scores": {"$gt": 80, "$lt": 85}} true for a document whose scores are [70, 90], and how do you express "one score between 80 and 85"?

    Show answer

    Each operator on an array path is satisfied independently by any element: 90 is above 80 and 70 is below 85. $elemMatch applies all conditions to the same element.

  2. A team stores a user's notification feed as an array inside the user document. What breaks first as the product grows?

    Show answer

    Write cost: each notification rewrites an ever larger document, long before the 16 MiB cap. Feeds belong in their own collection or bounded buckets.

  3. Why does the oplog record $inc: 1 as a $set of the new value?

    Show answer

    Oplog entries must be idempotent because a secondary may apply an entry more than once during recovery. Setting the final value twice is harmless; incrementing twice is not.

  4. When does a document store give you atomicity without a transaction?

    Show answer

    When every field the operation changes lives in one document, because a single-document write is atomic and isolated. Designing aggregates around invariants is how you use that.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →