Document Database Design: Schema Patterns for MongoDB and Beyond
โSchemalessโ is the most misleading word in databases. A document store like MongoDB wonโt enforce a schema, but your application depends on one with every field it reads โ the schema just moved from the database into your code, where itโs invisible and unversioned unless you design deliberately. Teams that treat document databases as a place to throw arbitrary JSON end up with the worst of both worlds: relational rigidity in the code and chaos in the data.
This post covers how to design document schemas on purpose: finding aggregate boundaries, the pattern catalog that experienced MongoDB teams rely on, indexing, and managing schema change. It assumes youโve read the NoSQL modeling overview in this series (design from access patterns, embed vs. reference).
The Core Unit: The Aggregate
The right mental model for a document isnโt โa row with flexible columnsโ โ itโs an aggregate: a cluster of data that the application treats as one unit, reads together, and updates together. The design question for every document collection is: where does the aggregate boundary sit?
A blog is the classic teaching example because it has one of everything. Consider posts, comments, authors, and tags:
- Post + its comments? Read together on the post page โ candidate for embedding. But comments are unbounded on a popular post, and โshow me all comments by this userโ crosses post boundaries. Judgment call.
- Post + author? Authors exist independently and appear on many posts โ reference, with maybe a small embedded snapshot (name, avatar) for display.
- Post + tags? Tiny, bounded, read with the post โ embed as an array.
{ "_id": "post_481", "title": "Why our migration took a year", "author": { "id": "u_12", "name": "Asha Patel" }, "tags": ["postgres", "migrations"], "comment_count": 214, "recent_comments": [ { "user": "u_87", "text": "Great writeup", "at": "2026-06-30T10:12:00Z" } ]}This document demonstrates the answer most mature designs converge on for the comments dilemma: the subset pattern โ embed the handful you actually display (the most recent five), keep the full set in a separate comments collection, and maintain a comment_count. One read renders the page; the โview all 214 commentsโ click pays for the second query only when a user actually asks for it.
The Pattern Catalog
A handful of named patterns solve most recurring document-design problems. Knowing them by name shortcuts a lot of rediscovery:
Extended reference. When referencing another document, embed the few fields you need for display alongside the ID โ author: {id, name} above โ so the common read avoids a second lookup. The embedded fields are a mirror; decide how they refresh (background job, on-write fan-out, or โstale is fineโ).
Subset. As above: embed the top-N of a large related set, store the remainder separately. Fits comments, reviews, order history previews.
Bucketing. For high-volume series data (metrics, readings, messages), one document per event creates millions of tiny documents, while one document per device grows unboundedly. The middle path: one document per device per hour/day, with readings in an array. Bounded documents, few of them, range-readable. (Time-series modeling gets a dedicated post in this series.)
Computed pattern. Store precomputed aggregates (comment_count, avg_rating) updated on write, instead of recalculating on every read. This is materialized-view thinking applied inside a document.
Polymorphic collection. Different-but-related shapes in one collection, distinguished by a type field: a notifications collection holding email, SMS, and push variants. Works because document stores donโt force uniform columns; keep the shared core of fields consistent so queries across types stay sane.
Schema versioning. Add a schema_version field from day one. When the shape changes, new writes carry v: 2 and readers handle both until a lazy migration completes. More on this below.
What Not to Do
The unbounded array. The single most common production incident in document databases: an embedded array (events, logs, followers) that grows until documents hit the 16 MB cap or every update rewrites megabytes. Rule: embed only what has a natural bound. โFollowersโ does not.
Relational cosplay. Fifteen collections of tiny normalized documents joined with $lookup everywhere. Youโve rebuilt a relational database on an engine without a query planner built for joins. If your design is dominated by references, thatโs a signal the workload wanted a relational store.
One giant everything-document. The opposite failure: customer + all orders + all tickets + all preferences in one document. Every reader loads everything, every writer contends on one document, and the working set balloons. Aggregate boundaries exist to be boundaries.
Ignoring atomicity boundaries. In MongoDB, a single-document update is atomic; multi-document transactions exist but cost more and are frequently a design smell. Shape aggregates so that invariants you must protect (โorder total equals sum of linesโ) live inside one document. If your invariants keep spanning documents, revisit the boundaries.
Indexing: The Half of Design Everyone Defers
Document flexibility doesnโt exempt you from index discipline โ an unindexed query scans the whole collection exactly like any database.
- Index every field your queries filter or sort on; use compound indexes matching your real filter+sort combinations (equality fields first, then sort field โ the same leftmost logic as relational B-trees).
- Multikey indexes cover array fields (
tags) transparently โ one of the document modelโs genuinely pleasant features. - Use covered queries for hot paths: if the index contains every field the query returns, the engine never touches the documents.
- Watch index count on write-heavy collections; each index taxes every insert, exactly as in relational systems.
Run explain() on your top queries before launch. โCOLLSCANโ on a large collection is the same emergency a Seq Scan is in PostgreSQL.
Schema Evolution Without Downtime
Because the database doesnโt enforce shape, you must manage change. The battle-tested recipe:
- Version every document (
schema_version: 1). - Write new shape, read both. Deploy readers that handle v1 and v2, then start writing v2.
- Migrate lazily. Upgrade documents on touch (when read/written), with an optional background sweep for the long tail.
- Retire the old reader once the sweep confirms no v1 remains.
This avoids the โmigrate 200 million documents in one maintenance windowโ cliff that big-bang migrations create. MongoDB also supports optional JSON Schema validation per collection โ turning it on in moderate mode gives you guardrails for new writes without breaking legacy documents. Flexible schema is a tool for evolving deliberately, not a permission slip to stop designing.
A Decision Checklist
Before shipping a collection design, answer these in writing:
- What are the top five queries, and how many document reads does each one cost? (Target: one for the hot paths.)
- Is every embedded array bounded? Whatโs the realistic maximum?
- For every duplicated field: snapshot or mirror? What refreshes mirrors?
- Do your critical invariants live within single documents?
- Does every query have a supporting index, verified with
explain()? - Is
schema_versionpresent, and does the team know the lazy-migration recipe?
Document databases shine when your data naturally clumps into aggregates that screens consume whole โ catalogs, profiles, orders, content. Design the clumps deliberately, bound the arrays, name your patterns, and version your shapes, and the โschemalessโ store becomes one of the most pleasant persistence layers there is. Skip the design step, and the schema still exists โ itโs just scattered across every service that ever wrote a document, waiting to disagree.