Schema Design Patterns at Scale

MongoDB Intermediate/Advanced — Schema Design Patterns at Scale
MongoDB Intermediate/Advanced
Chapter 3 · 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.

// Three different "shapes," one collection, one discriminator field { user_id: "U1", type: "login", timestamp: ISODate("2026-07-01"), ip: "203.0.113.4" } { user_id: "U1", type: "purchase", timestamp: ISODate("2026-07-02"), order_id: "O55", amount: 42.50 } { user_id: "U1", type: "profile_edit", timestamp: ISODate("2026-07-03"), field_changed: "email" }

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
{ sensor_id: "S1", timestamp: "10:00:01", temp: 21.4 } { sensor_id: "S1", timestamp: "10:00:02", temp: 21.5 } // ...3,600 documents for one sensor, one hour
With the Bucket Pattern
{ sensor_id: "S1", hour: "2026-07-06T10:00", readings: [ { t: "10:00:01", temp: 21.4 }, { t: "10:00:02", temp: 21.5 } // ... ], count: 3600 }

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.

// Every new order updates the precomputed total directly — no re-aggregation needed to read it db.customers.updateOne( { _id: "C104" }, { $inc: { lifetime_spend: 42.50, order_count: 1 } } )

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.

PatternProblem It SolvesTrade-off
PolymorphicOne 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 documentsWrites become updates-to-a-bucket instead of simple inserts
ComputedRe-aggregating the same value on every read is wastefulStored value can drift if a write path forgets to update it
Refactor to referencingAn embedded array's growth is unbounded, or nearing 16MBReads 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):

{ sensor_id: "S1", hour: "2026-07-06T10:00", readings: [ /* ...3,600 entries... */ ], summary: { min: 20.9, max: 22.1, avg: 21.5 } }

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.

→ Solution

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.

→ Solution

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.

→ Solution

⚠️ Gotcha: "Bucket Pattern" and $bucket Are Two Different Things

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.