Skip to content
ExponaBLOG
MONGODB, NOSQL & DATA MODELING · DAY 089 / 100

Polymorphic documents: a practical MongoDB & NoSQL guide

Different shapes in one collection.

Expona Admin7 min read

Polymorphic documents: a practical MongoDB & NoSQL guide

Real-world applications rarely fit into a single rigid schema. Your e-commerce system might need to store product variants—electronics with specs, clothing with sizes, or digital licenses with expiration dates—all in one collection. MongoDB's flexible document model makes this possible, but without discipline, polymorphic documents become a maintenance nightmare. This guide shows you how to structure, query, and validate different document shapes in a single collection.

Why polymorphic documents matter

In traditional SQL, you'd normalize this into separate tables. MongoDB lets you keep related data together, avoiding expensive joins and keeping the query logic simpler. A payment processor we worked with stored invoices, credit memos, and refund notifications in one collection. The polymorphic approach meant a single query could fetch a customer's full financial history without multiple joins. The trade-off is that you need clear boundaries and consistent patterns to prevent chaos.

Polymorphic documents work best when:

  • You have a set of well-defined document types (not unlimited variations)
  • Types share a common core of fields but diverge in optional details
  • Queries often filter by type or need mixed results
  • Your application code is strong enough to validate per-type rules

Add a type discriminator field

The first rule: always include an explicit type field. This is your north star for determining what shape a document takes and how to deserialize it in your application.

javascript

// Good: explicit type discriminator
{
  _id: ObjectId("..."),
  type: "electronics",
  name: "MacBook Pro",
  price: 2499,
  weight_kg: 1.6,
  warranty_months: 12,
  processor: "M3",
  ram_gb: 8
}

{
  _id: ObjectId("..."),
  type: "clothing",
  name: "Cotton T-Shirt",
  price: 29.99,
  weight_kg: 0.15,
  size: "M",
  color: "navy",
  material: "100% cotton"
}

{
  _id: ObjectId("..."),
  type: "digital_license",
  name: "Photo Editor Pro",
  price: 79.99,
  weight_kg: 0,
  license_key: "ABC-1234-XYZ",
  expires_at: ISODate("2025-12-31T23:59:59Z"),
  max_activations: 3
}

Notice that type, name, and price are common across all three. The specific fields (processor, size, license_key) appear only in their respective types. This clarity is essential when you're running queries at 2 AM on a production incident.

Keep the type value consistent. Use lowercase, kebab-case strings like electronics, clothing, digital_license. Avoid generic names like type: "product"—be specific. Document the valid type values in your schema or README so new team members understand the contract.

Share common fields across types

Identify fields that logically exist on every variant and place them at the root level. This makes filtering, sorting, and projecting much easier.

javascript

// Shared fields at root level
db.products.find({
  price: { $gt: 50, $lt: 500 },
  created_at: { $gte: ISODate("2024-01-01") }
})

In a real e-commerce system, we kept sku, name, price, inventory_count, created_at, updated_at, and seller_id at the root. Any query that doesn't care about the specific type can run directly on these fields without parsing the type first.

For fields that are common in concept but differ in structure, consider a specs or attributes subdocument:

javascript

{
  _id: ObjectId("..."),
  type: "shoes",
  name: "Running Shoes",
  price: 120,
  attributes: {
    size: 10,
    width: "M",
    color: "black",
    material: "mesh"
  }
}

{
  _id: ObjectId("..."),
  type: "laptop",
  name: "Dell XPS",
  price: 1299,
  attributes: {
    cpu: "Intel i7",
    ram_gb: 16,
    storage_gb: 512,
    screen_inches: 13.4
  }
}

This pattern works when the attribute set is truly dynamic, but be cautious: you lose the ability to query nested fields efficiently without additional work. If you can define the specific fields upfront (as in our first example), do that instead.

Index the type field when filtering

If you're filtering by type frequently—and you will be—index it immediately.

javascript

// Create index on type field
db.products.createIndex({ type: 1 })

// Compound index: type + common filter field
db.products.createIndex({ type: 1, price: 1 })

// For pagination or time-based queries
db.products.createIndex({ type: 1, created_at: -1 })

Most queries will look like this:

javascript

// Fetch all electronics sorted by price
db.products.find({ type: "electronics" })
  .sort({ price: 1 })
  .limit(20)

// Fetch recent orders and refunds for a customer
db.transactions.find({
  type: { $in: ["order", "refund"] },
  customer_id: ObjectId("...")
})
  .sort({ created_at: -1 })

The type index makes both queries fast. Without it, MongoDB scans the entire collection even if it's a small subset. We learned this the hard way during a Black Friday spike when a missing index turned a 5ms query into a 500ms collection scan.

Evaluate whether you need compound indexes. If you always filter by type and then by price, a compound index { type: 1, price: 1 } is better than two separate indexes. Use the MongoDB query explain output to verify your indexes are being used.

Validate per type in application code

MongoDB schema validation can help, but it's complex for polymorphic documents. Most teams rely on application-level validation instead.

javascript

// Using Zod for runtime validation
import { z } from "zod"

const baseProductSchema = z.object({
  _id: z.instanceof(ObjectId),
  type: z.enum(["electronics", "clothing", "digital_license"]),
  name: z.string().min(1).max(500),
  price: z.number().positive(),
  weight_kg: z.number().nonnegative()
})

const electronicsSchema = baseProductSchema.extend({
  type: z.literal("electronics"),
  processor: z.string(),
  ram_gb: z.number().positive()
})

const clothingSchema = baseProductSchema.extend({
  type: z.literal("clothing"),
  size: z.enum(["XS", "S", "M", "L", "XL", "XXL"]),
  material: z.string()
})

const digitalLicenseSchema = baseProductSchema.extend({
  type: z.literal("digital_license"),
  license_key: z.string().regex(/^[A-Z0-9-]+$/),
  expires_at: z.instanceof(Date),
  max_activations: z.number().int().positive()
})

const productSchema = z.discriminatedUnion("type", [
  electronicsSchema,
  clothingSchema,
  digitalLicenseSchema
])

// Validation function
function validateProduct(data) {
  const result = productSchema.safeParse(data)
  if (!result.success) {
    throw new Error(`Invalid product: ${result.error.message}`)
  }
  return result.data
}

// Usage in insert/update
async function upsertProduct(product) {
  const validated = validateProduct(product)
  return db.products.updateOne(
    { _id: validated._id },
    { $set: validated },
    { upsert: true }
  )
}

This approach gives you several advantages. You can define strict rules for each type without complex MongoDB schema syntax. Your IDE and TypeScript understand the shape of each variant. And validation happens close to the data mutation, making errors easier to trace.

For NestJS or similar frameworks, create a validator class per type:

javascript

class ProductService {
  async create(input: CreateProductDto) {
    let validator
    switch (input.type) {
      case "electronics":
        validator = new ElectronicsValidator()
        break
      case "clothing":
        validator = new ClothingValidator()
        break
      case "digital_license":
        validator = new DigitalLicenseValidator()
        break
      default:
        throw new BadRequestException(`Unknown type: ${input.type}`)
    }

    const errors = validator.validate(input)
    if (errors.length > 0) {
      throw new BadRequestException(errors)
    }

    return this.db.products.insertOne(input)
  }
}

Keep validation logic tied to the type. A digital license without an expiration date should fail loudly at write time, not create confusion during queries later.

Common mistakes

Omitting the type field. Without it, you're guessing the document shape based on which fields exist. This is fragile and makes queries harder to reason about. Always have an explicit discriminator.

Over-normalizing with subdocuments. Storing everything in a nested spec object makes queries painful and wastes the benefit of a flexible schema. Keep common and frequently-queried fields at the root.

Creating too many types. If you have 30 product types with only minor differences, consider whether you really need separate types or if a single schema with optional fields would be simpler. Polymorphism should reduce complexity, not increase it.

Skipping indexes on the type field. A missing index will hurt you. Type-based filtering is usually a leaf of your query tree; indexing it is almost always worth it.

Validating only in the database. MongoDB schema validation is powerful but hard to debug at 3 AM. Validate in your application where you control error messages and can test the rules easily.

Key takeaways

  • Always include an explicit, lowercase type discriminator field to identify document variants
  • Place common fields at the root level so queries don't need to know the type to filter on them
  • Index the type field immediately and consider compound indexes for frequent filter combinations
  • Validate per-type rules in application code using tools like Zod or class-based validators
  • Keep the number of types small and well-defined; if you have too many, revisit your design
  • Document the valid type values and required fields for each variant in your codebase or README
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles