# MongoDB Schema Design for E-Commerce: Lessons from Sans Herbals
By Govind Bajaj · 2025

Tags: mongodb, mongoose, schema-design, ecommerce, database
Embedding vs referencing: the decision that determines whether your e-commerce API responds in 50ms or 5 seconds. Here's the framework I use.
The first version of the Sans Herbals database had 47 collections.

Every entity was its own collection with foreign key references. Products referenced categories. Orders referenced users, products, addresses, and payment records. Reviews referenced users and products. A single order page fired 8 database queries.

I refactored it down to 12 collections. The same order page now fires 1 query. The decision framework I used is what I'm sharing here.

## The Core Question: Embed or Reference?

Every MongoDB schema design decision comes down to one question: should this data live inside the parent document, or in its own collection with a reference?

**Embed** when:

- The embedded data is read together with the parent (same query)
- The embedded data doesn't grow unbounded (MongoDB docs are capped at 16MB)
- The embedded data is not updated independently across multiple parents

**Reference** when:

- The data is updated frequently and independently of the parent
- The data is referenced from multiple parents (single source of truth)
- The data set is large or unbounded (would bloat parent documents)

This sounds abstract. Here's how it applies to e-commerce.

## Product Documents: Embed Categories, Reference Inventory

```js
// Product schema — embed category info, reference inventory
const productSchema = new Schema({
  name: { type: String, required: true },
  slug: { type: String, required: true, unique: true },
  price: { type: Number, required: true },
  // Embedded: category data is read with every product query
  category: {
    id: { type: Schema.Types.ObjectId, ref: 'Category' },
    name: String,
    slug: String
  },
  // Embedded: images are part of the product, bounded (max 10)
  images: [{
    url: String,
    alt: String,
    order: Number
  }],
  // Referenced: inventory changes frequently, tracked separately
  inventoryId: { type: Schema.Types.ObjectId, ref: 'Inventory' }
}, { timestamps: true })
```

Category info is embedded because every product page shows the category name. Inventory is referenced because it changes on every purchase and needs ACID guarantees.

## Order Documents: Embed Everything

This is the most important schema decision in e-commerce. Orders should embed all data needed to display the order, even if that data duplicates other collections.

```js
// Order schema — embed everything for fast reads
const orderSchema = new Schema({
  orderNumber: { type: String, unique: true },
  status: {
    type: String,
    enum: ['pending', 'confirmed', 'shipped', 'delivered', 'cancelled'],
    default: 'pending'
  },
  // Embedded user snapshot — order history must show who ordered, even if user updates profile
  user: {
    id: { type: Schema.Types.ObjectId, ref: 'User' },
    name: String,
    email: String,
    phone: String
  },
  // Embedded items — each item snapshot at time of purchase
  items: [{
    productId: { type: Schema.Types.ObjectId, ref: 'Product' },
    name: String,      // snapshot: product name at purchase time
    price: Number,     // snapshot: price paid (may differ from current price)
    quantity: Number,
    variant: {
      name: String,
      value: String
    }
  }],
  // Embedded shipping address
  shippingAddress: {
    name: String,
    line1: String,
    line2: String,
    city: String,
    state: String,
    pincode: String
  },
  // Embedded payment summary
  payment: {
    method: { type: String, enum: ['cod', 'razorpay', 'wallet'] },
    status: { type: String, enum: ['pending', 'paid', 'failed', 'refunded'] },
    razorpayOrderId: String,
    paidAt: Date
  },
  pricing: {
    subtotal: Number,
    shipping: Number,
    discount: Number,
    total: Number
  }
}, { timestamps: true })
```

The order document is intentionally denormalized. Every field an admin needs to view or process an order is embedded. The orders collection is read-heavy — admins view orders constantly — and the embedded structure means one query per order.

## Why Embedding Is Safe for Orders

Orders are immutable after creation. The items, pricing, and address are snapshots. If a user updates their profile, past orders should still show the name and address at the time of purchase. This is exactly what embedding gives you for free.

For the rare cases where you need to update orders (status changes, refund processing), targeted updates work fine:

```js
// Update just the status field
await Order.findByIdAndUpdate(orderId, {
  'status': 'shipped',
  'shipping.trackingNumber': trackingNum
})
```

## The User Cart: A Hybrid Approach

The cart is the trickiest entity. It needs real-time updates but also fast reads. I use a hybrid approach:

```js
// Cart — reference products, embed minimal data for display
const cartSchema = new Schema({
  userId: { type: Schema.Types.ObjectId, ref: 'User', unique: true },
  items: [{
    productId: { type: Schema.Types.ObjectId, ref: 'Product' },
    quantity: Number,
    // Cached display data — updated when product changes
    snapshot: {
      name: String,
      price: Number,
      image: String
    }
  }],
  updatedAt: { type: Date, default: Date.now }
})
```

The cart references products for inventory checks but embeds a display snapshot so the cart page renders without additional queries. A background job updates the snapshot when product prices change.

## The Indexing Strategy

Schema design without indexing is half the work. These are the indexes I add for every e-commerce collection:

```js
// Product indexes
productSchema.index({ slug: 1 })           // product page lookups
productSchema.index({ 'category.slug': 1 }) // category filtering
productSchema.index({ price: 1 })          // price sorting
productSchema.index({ name: 'text' })      // text search

// Order indexes
orderSchema.index({ orderNumber: 1 })      // order lookups
orderSchema.index({ 'user.id': 1 })        // user's order history
orderSchema.index({ status: 1, createdAt: -1 }) // admin order list
orderSchema.index({ createdAt: -1 })       // recent orders
```

## Takeaways

- Embed data that's read with the parent, bounded in size, and not independently updated
- Reference data that changes frequently, needs a single source of truth, or is unbounded
- Orders should embed everything — they're snapshots, not live references
- The embed vs reference decision should be driven by your query patterns, not normalization theory
- Add indexes for every query pattern before you launch — adding them later locks the database
- MongoDB's 16MB document limit is a hard ceiling — never embed unbounded arrays (like all a user's orders)