MongoDB Mastery
MongoDB Mastery lessons / Module 13: Schema Design Patterns

Patterns for read performance: computed, subset, extended reference, bucket, outlier

Level 3: Advanced30 min

The schema design patterns that turn expensive reads into cheap ones by doing work at write time or by storing data in the shape it is read.

This lesson is interactive. Run every example against an in-browser MongoDB, see explain() plans drawn as trees, and take the quiz.
Open the interactive lesson
You will learn to
  • Pre-compute aggregates with the computed pattern
  • Keep hot data small with the subset pattern
  • Avoid joins with extended references
  • Group time-ordered data with the bucket pattern
  • Handle rare huge cases with the outlier pattern

conceptThe concept

PatternProblemIdea
ComputedSame aggregation on every readStore the result (ratingAvg, reviewCount, totals) and update it on write or on a schedule
SubsetLarge document, but screens need only partEmbed the most recent or most relevant N children; keep the rest in their own collection
Extended referenceEvery read joins to fetch a few fieldsCopy the few stable fields you need along with the reference (customer name and city into the order)
BucketMillions of tiny time-ordered documentsOne document per entity per time window with an array of measurements and summary fields
OutlierA few documents are far larger than the restDesign for the common case; flag outliers (hasOverflow: true) and store their excess elsewhere
ApproximationHigh-frequency countersUpdate the counter every N events or with sampling when exactness is not required

whyWhy it matters

Most production applications read far more than they write (often 10:1 or 100:1). Spending a little at write time to make reads a single indexed lookup is usually the right trade. These patterns are the vocabulary architects use in design reviews and interviews.

Use caseReal-world scenario

The product page shows the average rating, number of reviews and the three most helpful reviews. Instead of aggregating 2,000 reviews per view, a background job (or a change stream consumer) maintains ratingAvg, reviewCount and topReviews on the product document: computed plus subset.

Try itExamples

Run and edit these in the interactive lesson.

Computed + subset: maintain product rating summary
db.reviews.aggregate([
  { $sort: { helpfulVotes: -1 } },
  { $group: {
      _id: "$productId",
      ratingAvg: { $avg: "$rating" },
      reviewCount: { $sum: 1 },
      topReviews: { $push: { title: "$title", rating: "$rating", votes: "$helpfulVotes" } } } },
  { $project: { ratingAvg: { $round: ["$ratingAvg", 2] }, reviewCount: 1, topReviews: { $slice: ["$topReviews", 3] } } },
  { $merge: { into: "products", on: "_id", whenMatched: "merge", whenNotMatched: "discard" } }
])
db.products.findOne({ _id: "P010" }, { name: 1, ratingAvg: 1, reviewCount: 1, topReviews: 1 })
Extended reference already in orders
db.orders.findOne({}, { orderNo: 1, "items.productId": 1, "items.name": 1, "items.price": 1, shipping: 1 })
Each line item references the product by id and copies the name and price needed for display.
Bucket pattern: events per customer per day
db.events.aggregate([
  { $match: { customerId: { $lte: 20 } } },
  { $group: {
      _id: { customerId: "$customerId", day: { $dateTrunc: { date: "$ts", unit: "day" } } },
      count: { $sum: 1 },
      maxLatency: { $max: "$latencyMs" },
      events: { $push: { ts: "$ts", type: "$type" } } } },
  { $merge: { into: "lab_event_buckets", on: "_id", whenMatched: "replace" } }
])
db.lab_event_buckets.countDocuments()
db.lab_event_buckets.findOne({}, { count: 1, maxLatency: 1, events: { $slice: 2 } })
Outlier flag
db.lab_books.drop()
db.lab_books.insertMany([
  { _id: 1, title: "Niche title", buyers: [11, 12, 13] },
  { _id: 2, title: "Bestseller", buyers: [21, 22, 23], hasOverflow: true }
])
db.lab_books_overflow.insertOne({ bookId: 2, page: 1, buyers: [24, 25, 26, 27] })
db.lab_books.find({ hasOverflow: true }, { title: 1 })

internalsUnder the hood

Fewer, smaller, denser documents mean more useful data per WiredTiger cache page and fewer index entries. The bucket pattern reduces index size dramatically: one index entry per bucket instead of per measurement. Time series collections (lesson below) apply bucketing automatically inside the server.

Avoid / preferDo this, not that

Avoid
// on every profile view
db.orders.aggregate([
  { $match: { customerId: 66 } },
  { $group: { _id: null, spent: { $sum: "$total" } } }
])
Prefer
// once, when the order is placed
db.customers.updateOne({ _id: 66 },
  { $inc: { "stats.spent": 698.52, "stats.orders": 1 } })

The computed pattern moves work from many reads to one write. Recomputing a lifetime total on every page view does the same work thousands of times.

Avoid
db.products.updateOne({ _id: "P001" }, { $push: { reviews: review } })   // all of them
Prefer
db.reviews.insertOne(review)
db.products.updateOne({ _id: "P001" }, { $push: { recentReviews: {
  $each: [review], $sort: { createdAt: -1 }, $slice: 10 } } })

The subset pattern keeps the 10 newest reviews on the product page document and every review in its own collection, so the hot document stays small.

PitfallsCommon mistakes

  • Computing on every read what could be stored once.
  • Embedding everything to "avoid joins" and creating huge documents; the subset pattern keeps only what is shown.
  • Copying volatile fields in an extended reference (stock level, status) that then go stale.
  • Forgetting to decide who updates the duplicated data and how often.

LimitsWhen not to use it

Every read pattern duplicates data. If the source changes often and every copy must be exactly right at all times, the duplication costs you extra writes and windows of staleness. The bucket and outlier patterns add code paths; skip them until volumes demand it.

SecuritySecurity notes

Extended references copy fields into more places. If you copy a customer's name and email into every order, an erasure request now has to find every copy. Copy the minimum, and prefer ids for personal data.

ChallengeTry it yourself

Build a subset on the fly: return product P001's name with only its 3 most recent reviews (rating and title).

Show one solution
One solution
db.products.aggregate([
  { $match: { _id: "P001" } },
  { $lookup: {
    from: "reviews", localField: "_id", foreignField: "productId",
    pipeline: [{ $sort: { createdAt: -1 } }, { $limit: 3 }, { $project: { _id: 0, rating: 1, title: 1 } }],
    as: "recentReviews"
  } },
  { $project: { name: 1, recentReviews: 1 } }
])

InterviewQuestions you should be able to answer

Describe the subset pattern.

When a parent has many children but the screen shows only a few, embed a bounded subset (most recent, most helpful) in the parent for the fast path, and keep the full set in a separate collection for "see all" pages. It keeps the working set small and avoids a second query for the common view.

How do you keep computed fields correct?

Update them in the same write path (e.g. $inc reviewCount when inserting a review, in a transaction if both must be consistent), or asynchronously from change streams or a scheduled $merge job when eventual consistency is acceptable. Document the staleness tolerance.

What is the outlier pattern?

Design the schema for the typical document (e.g. a book with a few hundred buyers embedded) and handle the rare extreme case (a bestseller with millions) with a flag and overflow documents, instead of forcing every document into a design that only the outliers need.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.