6.2 Core Schema Design Patterns
Key Takeaways
- The Polymorphic Pattern stores documents with different shapes in the same collection using a discriminator field (e.g., 'type') for unified querying.
- The Attribute Pattern transforms sparse attributes into key-value arrays ('[{ k: "color", v: "red" }]'), allowing 1 compound multikey index to cover hundreds of dynamic specs without hitting the 64-index limit.
- The Bucket Pattern groups streaming time-series data into time-bounded documents with pre-aggregated summaries, shrinking document count and index size.
- The Subset Pattern satisfies the 80/20 rule by embedding frequently accessed items in the main document while offloading complete history to a referenced collection.
- The Extended Reference Pattern copies read-critical fields to eliminate '$lookup' joins, while the Computed Pattern pre-calculates rollups on write to avoid on-the-fly aggregation.
6.2 Core Schema Design Patterns
Designing scalable schemas in MongoDB requires moving beyond naive normalization or indiscriminate embedding. Real-world applications encounter specific data access challenges: sparse product catalogs, high-frequency IoT telemetry, unbounded comments, and heavy read-to-write ratios.
To address these recurring engineering challenges, MongoDB architects established six Core Schema Design Patterns:
- Polymorphic Pattern
- Attribute Pattern
- Bucket Pattern
- Subset Pattern
- Extended Reference Pattern
- Computed Pattern
Mastering these patterns enables developers to optimize the WiredTiger storage engine, reduce B-tree index footprints, keep the working set residing in RAM, and eliminate expensive $lookup aggregation stages.
1. The Polymorphic Pattern
The Problem
In object-oriented software engineering, classes inherit from common abstract bases (e.g., Vehicle -> Car, Truck, Motorcycle or Content -> Article, Video, Podcast). In relational databases, modeling inheritance requires Table-Per-Hierarchy (sparse columns with many nulls) or Table-Per-Type (multiple tables joined on primary keys), both of which are cumbersome and slow.
The Solution
The Polymorphic Pattern stores documents with different structural shapes inside the same collection. A designated discriminator field (commonly named type, kind, category, or schema_version) identifies the document sub-type. Shared attributes reside at the root level, while type-specific attributes are encapsulated in specialized fields or embedded subdocuments.
// Single 'content' collection storing polymorphic documents
// 1. Article Document
db.content.insertOne({
_id: ObjectId("66d5b100f1e8a93b4c5d6e31"),
type: "ARTICLE", // Discriminator
title: "Introduction to MongoDB Indexing",
author_id: ObjectId("66d5b100f1e8a93b4c5d6e01"),
published_at: ISODate("2026-09-01T08:00:00Z"),
word_count: 1450,
body: "Indexing in MongoDB is powered by B-trees..."
});
// 2. Video Document
db.content.insertOne({
_id: ObjectId("66d5b100f1e8a93b4c5d6e32"),
type: "VIDEO", // Discriminator
title: "Aggregation Pipeline Masterclass",
author_id: ObjectId("66d5b100f1e8a93b4c5d6e02"),
published_at: ISODate("2026-09-02T10:30:00Z"),
duration_seconds: 3600,
video_url: "https://cdn.example.com/videos/agg-pipeline.mp4",
resolutions: ["1080p", "4K"]
});
Querying and Indexing
Applications can query across all content types in a single query or filter by specific types using the discriminator field:
// Find all published content by an author regardless of format
db.content.find({ author_id: ObjectId("66d5b100f1e8a93b4c5d6e01") }).sort({ published_at: -1 });
// Compound index supporting discriminator queries
db.content.createIndex({ type: 1, published_at: -1 });
2. The Attribute Pattern
The Problem
E-commerce catalogs and product inventories often contain thousands of distinct product categories. Each category has unique, sparse specifications:
- Televisions:
screen_size,refresh_rate,panel_type,hdmi_ports - Shoes:
size,color,material,gender - Tires:
diameter,aspect_ratio,load_index,speed_rating
If modeled as top-level fields ({ screen_size: 65, refresh_rate: 120 }), supporting user searches across these attributes would require creating dozens or hundreds of individual indexes. MongoDB imposes a hard limit of 64 indexes per collection, and each index consumes substantial RAM and degrades write performance.
The Solution
The Attribute Pattern transforms sparse, heterogeneous fields into an array of key-value subdocuments (commonly k and v):
Before (Sparse Fields): { _id: 1, title: "4K TV", screen_size: 65, refresh_rate: 120 }
|
v (Attribute Pattern)
After (Key-Value Array): { _id: 1, title: "4K TV", specs: [
{ k: "screen_size", v: 65, u: "inches" },
{ k: "refresh_rate", v: 120, u: "Hz" }
]}
// Product document modeled with Attribute Pattern
db.products.insertOne({
_id: ObjectId("66d5b200f1e8a93b4c5d6e40"),
sku: "PROD-TV-4K-01",
title: "Ultra HD Smart TV 65 Inch",
category: "Electronics",
price: NumberDecimal("799.99"),
specs: [
{ k: "screen_size", v: 65, u: "inches" },
{ k: "refresh_rate", v: 120, u: "Hz" },
{ k: "resolution", v: "3840x2160" },
{ k: "hdr_support", v: true }
]
});
Indexing Strategy
A single compound multi-key index covers all search queries across every dynamic attribute:
// One index covers search on any attribute key-value combination!
db.products.createIndex({ "specs.k": 1, "specs.v": 1 });
// Query: Find TVs with screen_size >= 65
db.products.find({
specs: { $elemMatch: { k: "screen_size", v: { $gte: 65 } } }
});
Exam Tip: Whenever a scenario describes "sparse fields across diverse product categories", "avoiding the 64 index limit", or "searching arbitrary dynamic properties with a single index", the correct pattern is always the Attribute Pattern.
3. The Bucket Pattern
The Problem
In time-series, financial tick feeds, and Internet of Things (IoT) workloads, devices transmit measurements at high frequencies (e.g., 1 reading every second). Storing each measurement as a standalone document creates billions of tiny documents. This leads to massive B-tree index bloat, heavy document metadata overhead (the 16-byte BSON header per document), and millions of random disk I/O operations.
The Solution
The Bucket Pattern groups streaming data points into a single bucket document bounded by a time window (e.g., 1 hour, 1 day) or a maximum sample count (e.g., 200 readings). The bucket document stores common metadata, pre-computed summary metrics (min, max, sum, count), and an array of individual measurements.
// Bucket Document: 1 hour of temperature readings from sensor_east_01
db.sensor_buckets.insertOne({
_id: ObjectId("66d5b300f1e8a93b4c5d6e50"),
sensor_id: "SENSOR-EAST-01",
bucket_start: ISODate("2026-09-02T10:00:00Z"),
bucket_end: ISODate("2026-09-02T10:59:59Z"),
count: 3600,
summary: {
min_temp: 21.4,
max_temp: 24.8,
sum_temp: 83160.0,
avg_temp: 23.1
},
readings: [
{ timestamp: ISODate("2026-09-02T10:00:01Z"), temp: 21.4, humidity: 45 },
{ timestamp: ISODate("2026-09-02T10:00:02Z"), temp: 21.5, humidity: 45 },
// ... 3598 additional readings
]
});
Atomic Streaming Ingestion with $inc and $push
// Upserting incoming data into current hour's bucket
db.sensor_buckets.updateOne(
{
sensor_id: "SENSOR-EAST-01",
bucket_start: ISODate("2026-09-02T10:00:00Z"),
count: { $lt: 3600 } // Bucket capacity limit
},
{
$push: { readings: { timestamp: new Date(), temp: 22.8, humidity: 46 } },
$inc: { count: 1, "summary.sum_temp": 22.8 },
$min: { "summary.min_temp": 22.8 },
$max: { "summary.max_temp": 22.8 },
$setOnInsert: { bucket_start: ISODate("2026-09-02T10:00:00Z") }
},
{ upsert: true }
);
Benefits of the Bucket Pattern
- Reduces document count by orders of magnitude (e.g., 3,600:1 ratio).
- Shrinks index size so indexes remain entirely within RAM.
- Summary metrics provide instant analytical rollups without running aggregation pipelines over raw samples.
4. The Subset Pattern
The Problem
Many 1:N relationships experience the 80/20 Rule: 80% of application queries access only a small, recent, or top-ranked subset of the related data, while the full history is rarely viewed. Examples include:
- E-commerce products with 5,000 customer reviews (users only view the top 10 recent reviews on the product detail page).
- Movies with 200 cast members (users only view the top 5 lead actors on the main screen).
Embedding all 5,000 reviews inflates document size to several megabytes, wastes RAM, and degrades network throughput. Conversely, separating all reviews into another collection requires a $lookup join on every single product page load.
The Solution
The Subset Pattern splits the 1:N relationship into two components:
- Embedded Subset: Embed the top N most frequently accessed items (e.g., top 10 most recent reviews) directly inside the main document.
- Referenced Full Collection: Store the complete historical list in a separate collection using Parent Referencing.
// 1. Primary Product Document (Embeds Top 5 Reviews for Fast Page Rendering)
db.products.insertOne({
_id: ObjectId("66d5b400f1e8a93b4c5d6e60"),
title: "Wireless Noise-Canceling Headphones",
price: NumberDecimal("249.99"),
rating_average: 4.8,
review_count: 3420,
recent_reviews: [ // TOP SUBSET (Fixed to 5 items)
{
reviewer: "Alex K.",
rating: 5,
comment: "Exceptional sound clarity and battery life.",
date: ISODate("2026-09-01T14:00:00Z")
},
{
reviewer: "Maria G.",
rating: 5,
comment: "Best ANC for frequent travelers.",
date: ISODate("2026-08-30T09:15:00Z")
}
]
});
// 2. Full 'reviews' collection (Stores all 3,420 historical reviews)
db.reviews.insertOne({
_id: ObjectId("66d5b400f1e8a93b4c5d6e61"),
product_id: ObjectId("66d5b400f1e8a93b4c5d6e60"), // Parent Reference
reviewer: "Alex K.",
rating: 5,
comment: "Exceptional sound clarity and battery life.",
date: ISODate("2026-09-01T14:00:00Z")
});
When a user opens a product page, MongoDB fetches the product and its top reviews in a single fast read. If the user clicks "View all 3,420 reviews", the application queries the reviews collection with pagination (skip/limit).
5. The Extended Reference Pattern
The Problem
In normalized referencing, querying an operational document (e.g., an Order or Invoice) requires executing a $lookup stage to pull basic information (such as Customer Name or Supplier City) from the referenced document. In high-throughput transaction systems, joining collections on every read introduces significant CPU and memory overhead.
The Solution
The Extended Reference Pattern copies a small, frequently accessed subset of fields from the referenced document directly into the operational document at create time.
// Customer Document (Full master record)
db.customers.insertOne({
_id: ObjectId("66d5b500f1e8a93b4c5d6e70"),
name: "Precision Engineering Ltd",
tax_id: "US-9481029",
phone: "+1-408-555-0199",
billing_address: { street: "500 Main St", city: "San Jose", state: "CA", zip: "95112" },
credit_rating: "AAA",
internal_notes: "Key enterprise account managed by Sarah."
});
// Order Document (Uses Extended Reference Pattern)
db.orders.insertOne({
_id: ObjectId("66d5b500f1e8a93b4c5d6e71"),
order_number: "PO-88412",
order_date: ISODate("2026-09-02T11:00:00Z"),
status: "SHIPPED",
total_amount: NumberDecimal("5420.00"),
customer: { // EXTENDED REFERENCE (Only frequently needed fields)
_id: ObjectId("66d5b500f1e8a93b4c5d6e70"),
name: "Precision Engineering Ltd",
shipping_city: "San Jose",
phone: "+1-408-555-0199"
}
});
Point-in-Time Historical Accuracy
The Extended Reference Pattern also provides point-in-time snapshots. If a customer changes their corporate name or billing address next year, past orders and invoices must preserve the exact name and address that was valid at the time the order was placed. Denormalizing the extended reference creates a permanent, immutable record.
6. The Computed Pattern
The Problem
Applications frequently display summary calculations: total revenue for a merchant, total movie ticket sales, average student GPA, or total unread notifications. Running on-the-fly aggregation queries ($group, $avg, $sum) across millions of records on every user read request exhausts database CPU and increases read latencies.
The Solution
The Computed Pattern calculates rollup values incrementally during write operations (or via asynchronous background workers) and stores the persisted aggregate directly in the parent document.
// Movie document with pre-calculated computed aggregates
db.movies.insertOne({
_id: ObjectId("66d5b600f1e8a93b4c5d6e80"),
title: "Interstellar Odyssey",
release_year: 2026,
total_votes: 1250, // Computed Count
sum_stars: 6000, // Computed Running Sum
average_rating: 4.80 // Computed Value: (sum_stars / total_votes)
});
// When a new review with 5 stars is submitted, update computed fields atomically:
db.movies.updateOne(
{ _id: ObjectId("66d5b600f1e8a93b4c5d6e80") },
[
{
$set: {
total_votes: { $add: [ "$total_votes", 1 ] },
sum_stars: { $add: [ "$sum_stars", 5 ] },
average_rating: {
$divide: [
{ $add: [ "$sum_stars", 5 ] },
{ $add: [ "$total_votes", 1 ] }
]
}
}
}
]
);
7. Master Schema Design Patterns Summary Table
| Pattern | Core Problem Addressed | When to Apply | Key Structural Transformation | Primary Benefit |
|---|---|---|---|---|
| Polymorphic | Documents have different fields but share common traits | Inheritance hierarchies, CMS content, event logs | Use a discriminator field (type); store in single collection | Single collection for related types; unified queries |
| Attribute | Hundreds of sparse, dynamic product attributes | Product catalogs with varying specs, hitting 64-index limit | Transform sparse fields to key-value array [{ k: ..., v: ... }] | 1 compound multi-key index covers all attributes |
| Bucket | High-frequency streaming telemetry causing index bloat | IoT data, financial tick data, time-series metrics | Group samples into time/count-bounded bucket documents | Reduces document count 3600:1; compact indexes in RAM |
| Subset | 1:N relationship with 80/20 access skew (large histories) | Product reviews, movie cast, social media comments | Embed top N items in main doc; store full list in separate collection | Fast primary reads; working set stays small in RAM |
| Extended Reference | High-frequency $lookup joins on read paths | E-commerce orders, invoices, shipment tracking | Embed 2–4 frequently read fields from referenced doc into main doc | Eliminates $lookup joins; creates point-in-time snapshot |
| Computed | Expensive on-the-fly aggregations executed repeatedly | Totals, average ratings, running counts, financial balances | Pre-calculate aggregate during write and store scalar in doc | Read operations cost O(1) time with zero aggregation CPU |
An online retail platform sells products across 50 categories. Products have distinct technical specifications (e.g., screen size for TVs, thread count for sheets, horsepower for lawn mowers). Customers must be able to filter by any specification. The engineering team is approaching MongoDB's 64-index limit per collection. Which schema design pattern resolves this issue?
A movie streaming service displays a film detail page visited millions of times daily. The page shows basic film information and the 5 most recent reviews. Films can have up to 20,000 total reviews, but users rarely click 'View All Reviews'. Which pattern should the architect implement to maximize cache performance and prevent large document sizes?
An order management system processes 50,000 orders per hour. On every order status lookup, the application requires the customer's full name, phone number, and delivery city. Querying the customer collection with $lookup on every order read is causing excessive CPU load. Which design pattern eliminates this join overhead?
A weather monitoring system captures temperature and air pressure readings every second from thousands of weather stations. Storing each reading in a distinct document is causing severe index memory bloat. Which pattern groups these readings into time-bounded documents with pre-aggregated summary statistics?