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

MongoDB schema design patterns: best practices explained

Proven patterns for common problems.

Expona Admin8 min read

MongoDB schema design patterns: best practices explained

The difference between a MongoDB collection that scales gracefully and one that becomes a performance nightmare often comes down to schema design. We've seen teams ship features quickly with a flat document structure only to face query timeouts and storage bloat months later. This guide covers MongoDB schema design patterns: best practices explained through patterns we've used in production across e-commerce, CRM, and SaaS platforms.

Your schema is not just about data shape—it's about access patterns, query performance, and operational efficiency. Unlike relational databases where normalization is dogma, MongoDB gives you the flexibility to denormalize strategically. The trick is knowing when to embed, when to reference, and when to precompute.

The Subset Pattern: Embed the Most Recent Items

In many applications, you query the full collection of related items infrequently. Instead, you often need just the most recent entries. The subset pattern addresses this by embedding only a limited subset of related documents in the parent, keeping the document size manageable while avoiding separate queries.

Real-world scenario: An e-commerce order system where customers have hundreds of line items across all orders, but you typically only display the last 10 orders on the dashboard.

javascript
// Without subset pattern - separate collection
db.customers.findOne({ _id: customerId })
db.orders.find({ customerId }).sort({ createdAt: -1 }).limit(10)

// With subset pattern - embedded recent orders
{
  _id: ObjectId("..."),
  email: "user@example.com",
  recentOrders: [
    {
      orderId: ObjectId("..."),
      total: 149.99,
      status: "delivered",
      createdAt: ISODate("2024-01-15")
    },
    {
      orderId: ObjectId("..."),
      total: 89.50,
      status: "shipped",
      createdAt: ISODate("2024-01-10")
    }
  ],
  recentOrdersCount: 2
}

The subset pattern works by maintaining an array of the most recent N items within the parent document. When inserting a new related item, you add it to the array, then remove the oldest element if the array exceeds your limit.

javascript
// Add a new order and keep only the 10 most recent
db.customers.updateOne(
  { _id: customerId },
  [
    {
      $set: {
        recentOrders: {
          $slice: [
            { $concatArrays: [[newOrder], "$recentOrders"] },
            10
          ]
        },
        recentOrdersCount: { $min: [{ $add: ["$recentOrdersCount", 1] }, 10] }
      }
    }
  ]
)

This pattern dramatically reduces query complexity for common access patterns while keeping your working set small. We've used this in ride-hailing platforms to embed the last 5 trips in user profiles and in SaaS dashboards to embed the latest activity log entries.

The Computed Pattern: Store Precomputed Totals

Aggregations are powerful but expensive. When you need summary statistics frequently—totals, averages, counts—the computed pattern stores these values directly in the document. Instead of running aggregation pipelines on every query, you update precomputed fields when the underlying data changes.

Real-world scenario: A CRM system where you constantly display the total deal value for an account and the count of open opportunities, but deals are added or updated infrequently relative to how often you read these stats.

javascript
// Without computed pattern - expensive aggregation on every read
db.accounts.aggregate([
  { $match: { _id: accountId } },
  { $lookup: { from: "deals", localField: "_id", foreignField: "accountId", as: "deals" } },
  { $project: {
      totalDealValue: { $sum: "$deals.amount" },
      openDealsCount: {
        $size: { $filter: { input: "$deals", as: "d", cond: { $eq: ["$$d.status", "open"] } } }
      }
    }
  }
])

// With computed pattern - stored alongside account data
{
  _id: ObjectId("..."),
  name: "Acme Corp",
  totalDealValue: 450000,
  openDealsCount: 3,
  dealValueUpdatedAt: ISODate("2024-01-15T10:30:00Z")
}

When a deal is added or its status changes, you update the parent account document:

javascript
// When a deal is created
db.accounts.updateOne(
  { _id: accountId },
  {
    $inc: { totalDealValue: dealAmount, openDealsCount: 1 },
    $set: { dealValueUpdatedAt: new Date() }
  }
)

// When a deal is closed
db.accounts.updateOne(
  { _id: accountId },
  {
    $inc: { totalDealValue: -closedDealAmount, openDealsCount: -1 },
    $set: { dealValueUpdatedAt: new Date() }
  }
)

The computed pattern trades write complexity for read simplicity. It's ideal when reads vastly outnumber writes. In trading platforms, we've used this to cache position totals. In SaaS billing systems, we cache subscription counts and MRR.

The Bucket Pattern: Group Time-Series Data

Time-series data—events, measurements, logs—can generate millions of documents. Without bucketing, indexing becomes expensive and memory usage balloons. The bucket pattern groups time-series events into fixed time windows (buckets), keeping metadata at the bucket level and the individual events inside an array.

Real-world scenario: A travel platform tracking user activity events. Users generate dozens of events per day (page views, searches, bookings), and you need to query recent activity efficiently without storing each event as a separate document.

javascript
// Without bucket pattern - one document per event
{
  _id: ObjectId("..."),
  userId: "user123",
  eventType: "search",
  destination: "Paris",
  createdAt: ISODate("2024-01-15T14:32:00Z")
},
{
  _id: ObjectId("..."),
  userId: "user123",
  eventType: "view_hotel",
  hotelId: "h456",
  createdAt: ISODate("2024-01-15T14:33:00Z")
}
// ... thousands more

// With bucket pattern - events grouped by hour
{
  _id: ObjectId("..."),
  userId: "user123",
  date: ISODate("2024-01-15T14:00:00Z"),
  count: 12,
  events: [
    { type: "search", destination: "Paris", timestamp: ISODate("2024-01-15T14:32:00Z") },
    { type: "view_hotel", hotelId: "h456", timestamp: ISODate("2024-01-15T14:33:00Z") },
    // ... up to 12 events
  ]
}

When inserting an event, you either add it to the current bucket or create a new one if the time window has passed:

javascript
// Insert event into the appropriate bucket
const bucketDate = new Date(Math.floor(event.timestamp / 3600000) * 3600000) // Round to hour
db.userActivityBuckets.updateOne(
  { userId: event.userId, date: bucketDate },
  [
    {
      $set: {
        count: { $ifNull: ["$count", 0] },
        events: { $ifNull: ["$events", []] }
      }
    },
    {
      $set: {
        count: { $add: ["$count", 1] },
        events: { $concatArrays: ["$events", [event]] }
      }
    }
  ],
  { upsert: true }
)

Bucketing reduces document count by orders of magnitude while maintaining query ability. We've deployed this for tracking API calls in SaaS platforms, storing sensor readings in IoT applications, and aggregating user events in analytics.

The Extended Reference Pattern: Copy Frequently Used Fields

References keep data normalized, but they require lookups. The extended reference pattern embeds a small set of frequently accessed fields from the referenced document directly in the parent, avoiding a separate query for common operations.

Real-world scenario: A job board where listings contain a reference to a company. You frequently display company name and logo with every listing, but company details rarely change.

javascript
// Without extended reference - requires separate lookup
{
  _id: ObjectId("..."),
  title: "Senior Backend Engineer",
  companyId: ObjectId("c789"),
  postedAt: ISODate("2024-01-15")
}
// Requires: db.companies.findOne({ _id: ObjectId("c789") })

// With extended reference - frequently used fields embedded
{
  _id: ObjectId("..."),
  title: "Senior Backend Engineer",
  companyId: ObjectId("c789"),
  company: {
    name: "TechCorp Inc",
    logo: "https://cdn.example.com/techcorp.png",
    industry: "Software"
  },
  postedAt: ISODate("2024-01-15")
}

The key is choosing fields that are truly accessed frequently and change rarely. If a field changes often, you'll end up updating it across hundreds or thousands of documents, which defeats the purpose.

javascript
// When company logo changes, update all job listings for that company
db.jobListings.updateMany(
  { companyId: companyId },
  { $set: { "company.logo": newLogoUrl } }
)
// This is still more efficient than a join, but monitor update frequency

In trading platforms, we embed broker name and fee tier in each trade document. In ride-hailing apps, we embed driver rating and vehicle type in ride history. Choose fields that are query-critical but relatively stable.

Common Mistakes

We've seen teams misapply these patterns in ways that backfire:

  • Embedding too much data in subset: Embedding 100 items instead of 10 defeats the purpose. Keep subsets truly small and define explicit limits in code.
  • Computed fields that require complex recalculation: If updating a computed field means querying 10 other collections, you've just moved the problem. Use computed patterns only when recalculation is straightforward.
  • Buckets that are too granular or too coarse: Buckets by second create millions of documents. Buckets by month make individual item queries slow. Choose the window that matches your query patterns.
  • Extended references for fields that change frequently: If you embed a user's subscription status in a document, you'll update it constantly. Use references for truly stable data.
  • Over-denormalizing for "just in case" queries: Don't embed data you might need someday. Embed only for access patterns you know you have right now.

Key Takeaways

  • The subset pattern keeps parent documents lean while enabling fast queries for the most recent related items without additional lookups.
  • The computed pattern trades write complexity for read speed, ideal when aggregate statistics are read frequently but underlying data changes slowly.
  • The bucket pattern groups high-volume time-series events into fixed windows, reducing document count and improving query performance for analytics and activity logs.
  • The extended reference pattern embeds a small set of stable, frequently accessed fields to avoid lookups, but only when those fields rarely change.
  • Always design schema around your actual access patterns, not theoretical flexibility. Monitor query patterns in production and adjust schema as traffic evolves.
  • Document your denormalization decisions and the assumptions they depend on—this helps the team maintain schema integrity as requirements change.
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