pattern polymorphic
**Store related but different document shapes in one collection with a type discriminator.** This keeps shared queries and indexes simple while allowing type-specific fields. Common use cases: product catalogs with different product types, content management systems, event stores, and any domain with inheritance.
Overview
Use Polymorphic Pattern for Heterogeneous Documents
Store related but different document shapes in one collection with a type discriminator. This keeps shared queries and indexes simple while allowing type-specific fields. Common use cases: product catalogs with different product types, content management systems, event stores, and any domain with inheritance.
Incorrect (separate collections per subtype):
Using a separate collection per product type (e.g. products_books, products_electronics, products_clothing) means querying across all products requires multiple calls or $unionWith, shared indexes must be duplicated, adding new types requires new collections, and application code must branch on collection names.
Correct (single collection using optional fields):
Store all product types in one products collection. All documents share common fields (name, price, inStock); each type adds its own specific fields (books: author, isbn, pages; electronics: brand, wattage, batteryHours, warranty; clothing: size, color, material). If the categories are always fully disjoint, use a type discriminator field (e.g. "book", "electronics", "clothing"). Cross-type queries use shared fields; type-specific queries filter by type plus type-specific fields. If there is potential overlap (e.g. between different categories of users), you can omit this field and rely entirely on optional fields.
Index strategies for polymorphic collections:
// Strategy 1: Compound index with type first
// Best for: Queries that always filter by type
db.products.createIndex({ type: 1, price: 1 })
db.products.createIndex({ type: 1, name: 1 })
// Query uses index efficiently:
db.products.find({ type: "book", price: { $lt: 50 } })
// Strategy 2: Compound index with type second
// Best for: Queries that rarely filter by type
db.products.createIndex({ price: 1, type: 1 })
// Query across all types uses index:
db.products.find({ price: { $lt: 50 } })
// Strategy 3: Partial indexes for type-specific fields
// Best for: Fields that only exist on some types
db.products.createIndex(
{ author: 1 },
{ partialFilterExpression: { type: "book" } }
)
db.products.createIndex(
{ brand: 1, wattage: 1 },
{ partialFilterExpression: { type: "electronics" } }
)
// Strategy 4: Wildcard index for varying fields
// Best for: Many type-specific fields, ad-hoc queries
db.products.createIndex({ "specs.$**": 1 })
// Documents store type-specific data in specs:
{ type: "book", specs: { author: "...", isbn: "..." } }
{ type: "electronics", specs: { brand: "...", wattage: 20 } }
Query patterns across types:
// Pattern 1: Query all types with shared fields
db.products.find({ price: { $lt: 100 }, inStock: true })
.sort({ price: 1 })
// Pattern 2: Query specific type with type-specific fields
db.products.find({
type: "book",
pages: { $gt: 300 },
author: /bradshaw/i
})
// Pattern 3: Aggregation across types with type-specific handling
db.products.aggregate([
{ $match: { inStock: true } },
{ $group: {
_id: "$type",
count: { $sum: 1 },
avgPrice: { $avg: "$price" }
}
}
])
// Pattern 4: Faceted search with type breakdown
db.products.aggregate([
{ $match: { price: { $lt: 100 } } },
{ $facet: {
byType: [{ $group: { _id: "$type", count: { $sum: 1 } } }],
priceRanges: [
{ $bucket: {
groupBy: "$price",
boundaries: [0, 25, 50, 100],
default: "100+"
}
}
]
}
}
])
Validation per type:
// Use JSON Schema with discriminator-based validation
db.runCommand({
collMod: "products",
validator: {
$jsonSchema: {
bsonType: "object",
required: ["type", "name", "price"],
properties: {
type: { enum: ["book", "electronics", "clothing"] },
name: { bsonType: "string" },
price: { bsonType: "number", minimum: 0 }
},
oneOf: [
{
properties: { type: { enum: ["book"] } },
required: ["author", "isbn"]
},
{
properties: { type: { enum: ["electronics"] } },
required: ["brand"]
},
{
properties: { type: { enum: ["clothing"] } },
required: ["size", "color"]
}
]
}
},
validationLevel: "moderate"
})
Adding new types:
The polymorphic pattern makes adding types straightforward — no schema migration needed. Insert documents with the new type value and any type-specific fields. Add partial indexes for type-specific queries as needed, and update schema validation to include the new type if using strict validation.
When NOT to use polymorphic pattern:
- Completely different access patterns: If each type is queried independently with no cross-type queries, separate collections may be cleaner.
- Conflicting index requirements: If types need many different indexes, the index overhead may outweigh benefits.
- Strict type separation required: Regulatory or security requirements may mandate separate collections.
- Vastly different document sizes: If one type has 100-byte docs and another has 100KB docs, working set suffers.
- Type-specific sharding needs: Different types may need different shard keys.
Verify with
// Get type distribution
db.products.aggregate([
{ $group: {
_id: "$type",
count: { $sum: 1 },
avgSize: { $avg: { $bsonSize: "$$ROOT" } }
}
},
{ $sort: { count: -1 } }
])
// Check for missing type field
db.products.countDocuments({ type: { $exists: false } })
Reference: Polymorphic Schema Pattern