Patterns for flexibility: polymorphic, attribute, versioning, trees, archive
Model varied shapes in one collection, index arbitrary attributes, evolve schemas without downtime, store hierarchies, and age out cold data.
- Store polymorphic documents with a type discriminator
- Use the attribute (key/value) pattern with one index
- Version documents and migrate lazily
- Choose a tree pattern: parent reference, child references, ancestors array, materialized path
conceptThe concept
- Polymorphic: documents of related types share a collection with a discriminator (
category,type) and common fields indexed; type-specific fields vary. - Attribute: many optional, searchable attributes become an array of
{ k, v }pairs with one compound index{ "attrs.k": 1, "attrs.v": 1 }instead of dozens of indexes. Alternatively, a wildcard index on a sub-document. - Schema versioning: a
schemaVersionfield; the application reads all versions, writes the newest, and documents migrate lazily on update or in background batches. - Trees: parent reference (
managerId, walk with$graphLookup), array of ancestors (fast subtree queries), materialized path (",electronics,audio,"queried with an anchored regex). - Archive: move cold data to another collection, cluster or object storage (online archive); TTL indexes for data with a fixed lifetime.
whyWhy it matters
Requirements change. These patterns let you add product types, attributes and fields without a stop-the-world migration, which is one of the main reasons teams choose a document database.
Use caseReal-world scenario
The catalog's electronics have brand, warrantyMonths; books have author, pages; groceries have expiryDays. A wildcard index on attributes.$** lets the filter UI query any attribute. When price moves to a { amount, currency } object, documents get schemaVersion: 2 as they are updated.
Try itExamples
Run and edit these in the interactive lesson.
db.products.aggregate([
{ $group: { _id: "$category", attributeKeys: { $addToSet: { $map: { input: { $objectToArray: "$attributes" }, as: "a", in: "$$a.k" } } } } },
{ $project: { attributeKeys: { $reduce: { input: "$attributeKeys", initialValue: [], in: { $setUnion: ["$$value", "$$this"] } } } } },
{ $sort: { _id: 1 } }
])db.products.createIndex({ "attributes.$**": 1 })
db.products.find({ "attributes.author": "M. Kleppmann" }, { name: 1 }).explain("executionStats")db.lab_items.drop()
db.lab_items.insertMany([
{ sku: "TV-1", attrs: [{ k: "screen", v: 55 }, { k: "hdr", v: true }] },
{ sku: "TV-2", attrs: [{ k: "screen", v: 65 }, { k: "refresh", v: 120 }] }
])
db.lab_items.createIndex({ "attrs.k": 1, "attrs.v": 1 })
db.lab_items.find({ attrs: { $elemMatch: { k: "screen", v: { $gte: 60 } } } }, { _id: 0, sku: 1 })db.employees.aggregate([
{ $match: { _id: 2 } },
{ $graphLookup: { from: "employees", startWith: "$_id", connectFromField: "_id", connectToField: "managerId", as: "reports", depthField: "level" } },
{ $project: { name: 1, title: 1, totalReports: { $size: "$reports" }, direct: { $size: { $filter: { input: "$reports", as: "r", cond: { $eq: ["$$r.level", 0] } } } } } }
])db.lab_users.drop()
db.lab_users.insertMany([{ _id: 1, name: "Old style", phone: "555-1" }, { _id: 2, schemaVersion: 2, name: "New style", contact: { phones: ["555-2"] } }])
db.lab_users.updateMany({ schemaVersion: { $exists: false } }, [
{ $set: { schemaVersion: 2, contact: { phones: ["$phone"] } } }, { $unset: "phone" }
])
db.lab_users.find()internalsUnder the hood
A wildcard index stores an entry per (path, value) pair under the indexed sub-document, so it behaves like many single-field indexes in one structure. It cannot support sorts on multiple fields or compound predicates across paths as well as a dedicated compound index, so use it for long-tail attributes and keep dedicated indexes for the hot ones.
Avoid / preferDo this, not that
{ size_s: 3, size_m: 10, color_red: true, voltage_220: true }
// plus one index per possible field{ attrs: [{ k: "size", v: "M" }, { k: "color", v: "red" }] }
db.products.createIndex({ "attrs.k": 1, "attrs.v": 1 })The attribute pattern turns many optional fields into one array, so one compound index serves every attribute lookup.
db.users.updateMany({}, { $rename: { phone: "contact.phone" } })
// 50 million documents, and the app expects the new shape at once// new writes: { schemaVersion: 2, contact: { phone: "..." } }
// readers handle version 1 and 2; a background job migrates in batchesThe schema versioning pattern lets old and new shapes coexist, so the migration can run gradually instead of as one risky big bang.
PitfallsCommon mistakes
- One collection per product type, which makes cross-type queries painful.
- One index per optional attribute; use the attribute pattern or a wildcard index.
- Big-bang migrations of millions of documents during peak hours instead of lazy or batched migration.
- Choosing a tree representation without knowing whether reads walk up (ancestors) or down (subtrees).
LimitsWhen not to use it
A polymorphic collection turns into a junk drawer when its types share nothing: give unrelated types their own collections. The attribute pattern loses per-field types and makes range queries over mixed value types awkward, so keep frequently filtered fields as real fields.
SecuritySecurity notes
Flexible does not mean unvalidated. Use $jsonSchema with oneOf to describe each allowed shape; otherwise unexpected fields such as isAdmin slip in alongside the legitimate variations.
ChallengeTry it yourself
Turn each product's attributes subdocument into key/value pairs and count how many products have each attribute key.
Show one solution
db.products.aggregate([
{ $project: { attrs: { $objectToArray: "$attributes" } } },
{ $unwind: "$attrs" },
{ $group: { _id: "$attrs.k", products: { $sum: 1 } } },
{ $sort: { products: -1, _id: 1 } }
])InterviewQuestions you should be able to answer
How do you evolve a schema in production without downtime?
Add a schemaVersion field. Deploy application code that can read both versions and writes the new one. Migrate in the background in throttled batches (or lazily on next write). Update the validator to accept both during the transition, then tighten it once all documents are migrated. Never require a big-bang rewrite.
When would you use a wildcard index?
For user-defined or highly variable attributes where you cannot predict query fields: a wildcard index on attributes.$** serves equality and range queries on any sub-path. For known hot query shapes, a compound index is still better (sorts, multi-field predicates, coverage).
QuizCheck your understanding
4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.