Schema Design Patterns at Scale
🏗️ Schema Design Patterns at Scale
mongodb1-5 framed schema design as a single choice: embed, or reference. At real scale that choice needs more nuance — three named patterns for problems that show up repeatedly, and a clear signal for when a schema that started out embedded has outgrown embedding and needs refactoring toward references.
Recap: Embedding vs. Referencing
Embedding nests related data directly inside a parent document — fast to read as one unit, no join needed. Referencing stores an ID and looks the related data up separately (via $lookup, Chapter 1) — more flexible, but costs a join. Every pattern below is really a more specific answer to that same underlying trade-off.
The Polymorphic Pattern
Sometimes a collection needs to hold documents that represent genuinely different kinds of the same broad concept — a user_activity collection logging logins, purchases, and profile edits, all sharing a user_id and a timestamp, but each carrying completely different extra fields. Cramming every possible field from every activity type onto one rigid shape wastes space and makes queries confusing. The polymorphic pattern keeps them in one collection anyway, distinguished by a discriminator field (commonly named type) that tells you which shape to expect.
Querying "everything this user did" is one simple find({ user_id: "U1" }) across all types; querying "just their purchases" adds type: "purchase" to the filter. This is the same discriminator idea mongodb1-6's BSON type chapter used for $jsonSchema validation, just applied to distinguishing whole documents rather than validating one.
The Bucket Pattern
Naming note first: this is a schema design pattern, unrelated to the $bucket aggregation stage from Chapter 2 — they share a name because they solve conceptually similar range-grouping problems, but one shapes how you store data and the other shapes how you query it.
Time-series-like data (sensor readings, log events) often arrives one small document at a time — a document per reading, every few seconds, forever. That creates two problems: an enormous number of tiny documents, and an index entry for every single one of them. The bucket pattern groups many readings that fall in the same time window into one larger document instead.
One Document Per Reading vs. One Bucket Per Hour
Without Bucketing
With the Bucket Pattern
One document per sensor per hour, holding an array of that hour's readings, drastically cuts both document count and index size — at the cost of updating (appending to) an existing bucket document instead of always inserting a brand-new one.
The Computed Pattern
Chapter 1's aggregation pipelines can always compute a total or a count on demand — but doing that on every single read of frequently-accessed data is wasted, repeated work if the underlying data doesn't actually change very often. The computed pattern precomputes an expensive value once and stores it directly on the document, updating it incrementally as writes happen — the same $inc operator from mongodb1-4's page-view-counter example, just applied to a running total instead of a single counter.
Reading lifetime_spend is now a plain field lookup — no $group required — at the cost of that value only being as accurate as the last write that updated it, and needing every code path that creates an order to remember to run the increment.
When Embedding Breaks Down
Embedding Is Still Fine When
The embedded data has a bounded, predictable size, is almost always read together with its parent, and isn't updated independently by unrelated parts of the application.
Time to Refactor to Referencing When
An array can grow without any real limit (comments, readings, log entries), the embedded data is frequently read or updated on its own, or the parent document is creeping toward MongoDB's hard 16MB per-document size limit.
| Pattern | Problem It Solves | Trade-off |
|---|---|---|
| Polymorphic | One collection needs to hold genuinely different "shapes" | Every query and every reader needs to branch on the discriminator field |
| Bucket (schema) | Too many tiny time-series-style documents | Writes become updates-to-a-bucket instead of simple inserts |
| Computed | Re-aggregating the same value on every read is wasteful | Stored value can drift if a write path forgets to update it |
| Refactor to referencing | An embedded array's growth is unbounded, or nearing 16MB | Reads now need a $lookup join instead of being self-contained |
Combining Patterns: An IoT Dashboard
A sensor-monitoring collection using both the bucket pattern (grouping readings by hour) and the computed pattern (precomputing that hour's min/max/average so the dashboard never re-aggregates raw readings just to render a summary card):
The dashboard's live "current hour" card reads summary directly — a plain field lookup, no aggregation pipeline needed at render time — while the full readings array stays available for anyone who genuinely needs the raw detail.
💻 Coding Challenges
Challenge 1: Model Polymorphic Notifications
Design polymorphic documents for a notifications collection holding three kinds: a friend request, a comment reply, and a system announcement. Give each a sensible type value and its own type-specific fields.
Goal: Practice choosing which fields are shared across all types (belong outside any per-type structure) versus which are genuinely type-specific.
Challenge 2: Decide — Bucket or Not?
A fitness app logs one heart-rate reading per user every 5 seconds during a workout. Should this use the bucket pattern? Justify your answer, and if yes, propose a reasonable bucket boundary (e.g. per-workout, per-minute).
Goal: Practice recognizing the "many tiny, frequent documents" signal that motivates the bucket pattern, and picking a boundary that fits the data's natural shape.
Challenge 3: Spot the Embedding Refactor
A blog_posts collection embeds comments directly (per mongodb1-1's Challenge 2). One post has gone viral and now has 40,000 comments. What breaks, and what should the schema change to?
Goal: Practice applying this chapter's "time to refactor to referencing" signal to a concrete, previously-reasonable embedding decision that scale has broken.
It's easy to conflate this chapter's bucket pattern (a schema design decision made when you insert data) with Chapter 2's $bucket aggregation stage (a query-time grouping operation on data that's already stored). A collection built with the bucket pattern doesn't require using $bucket to query it, and using $bucket in a pipeline says nothing about how the underlying documents were designed. They share a name because both group things by range — that's the only real connection.
🎯 What's Next
The next chapter is Transactions in MongoDB — multi-document ACID transactions, when a document-model database actually needs them despite embedding reducing the need in the first place, and the session-based API that makes them work.