FullStack Course LogoFullStack Course
Module: MongoDB
MongoDB·135·14 MIN READ

135: MongoDB Data Modeling — Access Patterns, Embedding, Referencing, Schema Validation, and Anti-Patterns

TOPICS COVERED: MongoDB Data Modeling — Access Patterns, Embedding, Referencing, Schema Validation, and Anti-Patterns

Learning objectives

By the end of this lesson, you should be able to:

  • design MongoDB schemas around the way an application actually accesses data;
  • choose deliberately between embedding and referencing;
  • model one-to-one, one-to-few, one-to-many, and many-to-many relationships;
  • reason about document growth and bounded arrays;
  • apply the Extended Reference, Subset, Bucket, and Computed-style patterns conceptually;
  • recognize when polymorphic documents are appropriate;
  • use schema validation;
  • plan schema evolution;
  • spot joins or transactions that are symptoms of poor document boundaries;
  • understand the main multi-tenant modeling choices;
  • reason about denormalization and the consistency guarantees it requires.

Start from access patterns

The first modeling question in MongoDB is usually not:

What are my entities?

Start with the application's access patterns instead:

What does the application read and write together?

For an order page, one common read might need all of the following:

text
order header
customer snapshot
line items
totals
shipping address

If these values form the order aggregate and are normally displayed together, embedding them can let one read return the complete aggregate.

Relational instinct versus document model

With a relational, normalized design, these concerns might live in separate tables:

text
orders
order_items
addresses
customers

MongoDB can instead represent the order as one document:

javascript
{
  _id: ObjectId(...),
  customer: {
    id: ObjectId(...),
    name: "Maya",
    email: "..."
  },
  items: [
    {
      productId: ObjectId(...),
      name: "Notebook",
      quantity: 2,
      pricePaise: 12000
    }
  ],
  shippingAddress: {...},
  totals: {...}
}

The duplicated customer and product fields are intentional snapshots. An historical order should preserve what the customer bought, including the name and price represented at that time. A later product rename should not silently rewrite the history of an already placed order.

Denormalization is therefore not automatically a design error. In a document model, duplication can be the correct representation when it matches the required read pattern and the intended meaning of the data.

Embedding advantages

Embedding is a strong fit when related data belongs to one aggregate and is commonly used together. Its benefits include:

The practical payoff is that the database shape follows the unit the application already thinks about. An order page does not need to assemble an order from unrelated pieces before it can render; it can read the aggregate in the form in which it is used.

  • one read;
  • atomic updates within the document;
  • a natural aggregate boundary;
  • fewer joins;
  • good data locality.

Embedding disadvantages

The same choice has costs that need to be explicit:

Every duplicated field creates another place that may need an update, and every embedded child contributes to the size and write cost of its parent document.

  • duplication;
  • document growth;
  • larger updates;
  • repeated synchronization when a canonical field must change everywhere;
  • MongoDB document size limits.

Referencing advantages

References are useful when related data has an independent lifecycle or cannot be bounded comfortably. They provide:

  • an independent lifecycle;
  • less large-scale duplication;
  • protection against unbounded arrays;
  • a natural way to represent many relationships.

The trade-offs are:

The extra round trip or join is sometimes exactly the right cost. The point is to know when the independent lifecycle and bounded size are worth paying for, rather than treating either choice as universally better.

  • an additional query;
  • a $lookup;
  • an application-level join;
  • consistency work across multiple documents.

One-to-one

When two pieces of data are always read together and share the same lifecycle, embedding is usually the simpler boundary:

javascript
{
  profile: {
    displayName: "...",
    timezone: "Asia/Tokyo"
  }
}

Embed the profile in that case.

If the data is independently secured, very large, or managed through a different lifecycle, a separate collection may be the better fit. The relationship cardinality alone does not decide the model; access, ownership, and lifecycle do.

One-to-few

Order line items are often a bounded, small collection of values that is needed whenever the order is read. That is a good embedding candidate:

javascript
items: [...]

The useful qualifier is "bounded." A small list today is not enough justification if the domain allows it to grow without a meaningful limit.

One-to-many

Consider a blog author with millions of posts. Putting every post ID in the author's document creates an array that grows indefinitely:

Do not:

javascript
{
  userId,
  postIds: [
    // millions forever
  ]
}

Instead, put the reference on each post:

javascript
{
  _id,
  authorId,
  ...
}

Create an index that supports querying posts by authorId. This keeps the author document bounded and makes the large side of the relationship independently queryable.

Many-to-many

For a students-to-courses relationship, several models are possible:

  • references in one side;
  • references on both sides;
  • an enrollment collection.

When the relationship itself carries data, an enrollment collection is usually the natural model:

javascript
{
  studentId,
  courseId,
  enrolledAt,
  status,
  grade
}

Here, enrollment is more than a link. It has a date, a status, and a grade, so it deserves its own document boundary.

Boundedness test

Before embedding an array, answer these questions:

text
maximum expected count?
upper hard bound?
can it grow forever?
how often updated?
how often entire array read?

If you cannot identify a meaningful upper bound, avoid unbounded embedding. Also consider whether the full array is normally read and whether frequent changes would make the containing document a contention point.

An estimate such as "usually a few" is not the same as a domain guarantee. If a future feature can turn that list into an unlimited feed, model it as a separate collection or choose a bucket boundary before the first production document makes the assumption expensive to change.

Document growth

Repeatedly pushing into a very large document has several consequences:

  • larger reads and writes;
  • index expansion;
  • eventual document size-limit pressure;
  • contention on one document.

Time-series and event-style data often fit better in buckets or separate documents. The right choice depends on how the data is queried and grouped, but an ever-growing single document is a warning sign.

Duplication is a trade-off

Suppose an order stores the product name. Should the historical order name change if the product is renamed?

Often no.

In that case, the duplicated value is a domain-correct snapshot. It preserves the description that was shown or purchased at the time of the order.

Now consider a customer's current phone number duplicated in an account dashboard where the value is expected to stay current. That may be better modeled by referencing or querying the canonical customer record instead.

The distinction to make is whether the duplicated value is a:

text
snapshot

or a:

text
cached canonical field

The second category needs an explicit synchronization strategy. Without one, different documents will eventually disagree and nobody will know which value is authoritative.

Write this decision down alongside the schema. A historical field should not be "fixed" by an automatic synchronization job, while a cached canonical field should have an owner, an update path, and a way to detect or repair lag.

Extended Reference pattern

The Extended Reference pattern keeps the reference while copying a small set of fields that are frequently needed:

javascript
{
  customer: {
    id: ObjectId(...),
    displayName: "Maya"
  }
}

The copied displayName can satisfy a common display read without an extra lookup. The canonical customer document remains the source of truth for mutable profile data.

If displayName changes, decide explicitly whether an old order should change. That decision determines whether the copied value is a snapshot or a projection that must be synchronized.

Subset pattern

The Subset pattern embeds only the related data needed most often rather than the entire related collection.

For example, a product could keep a few recent reviews in the main document:

javascript
{
  recentReviews: [
    // last 3
  ]
}

The full review history remains in a separate collection. This makes the common product read cheaper while avoiding a permanently growing product document. It also creates update logic: new reviews must update the subset, and the subset must be rebuilt correctly when reviews change or are removed.

Computed pattern

The Computed pattern stores a value that would otherwise need to be calculated repeatedly:

javascript
{
  reviewCount: 238,
  averageRating: Decimal128("4.6")
}

The application can read these values instead of aggregating every review on every request. The cost moves to writes: each review write must maintain the computed values, or an asynchronous process must rebuild them.

This is a deliberate exchange of read performance for write complexity. It also means you need to decide how the application behaves while an asynchronous value is temporarily stale.

Bucket pattern

The Bucket pattern groups many small records into bounded buckets. For sensor or event data, a bucket might cover an hour or a day:

javascript
{
  deviceId,
  bucketStart,
  measurements: [...]
}

Buckets are useful when individual event documents would create an enormous document count and the application normally reads events in groups. The bucket boundary keeps growth bounded and gives queries a practical unit to target.

MongoDB time-series collections provide specialized behavior for time-series use cases. Prefer them when their behavior matches the workload rather than building a generic bucket design without checking the specialized option.

Polymorphic collections

A polymorphic collection stores documents with a shared shape where some fields vary by type. Notifications are a straightforward example:

javascript
{
  type: "email",
  to: "...",
  subject: "..."
}
javascript
{
  type: "sms",
  to: "...",
  message: "..."
}

The common fields can coexist with type-specific fields. Use a discriminator or type field and validator rules to make the permitted shapes explicit.

The collection should still represent one coherent domain concept. Avoid turning it into an unrelated junk drawer simply because MongoDB permits documents with different fields.

If each type needs unrelated indexes, permissions, retention rules, and processing code, separate collections may be easier to operate and understand.

Schema validation

MongoDB is flexible about document shape, but flexibility does not mean that every writer should be allowed to store anything. Use $jsonSchema when the database should enforce structural requirements.

For example:

javascript
db.createCollection("orders", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: [
        "tenantId",
        "status",
        "items",
        "createdAt"
      ],
      properties: {
        tenantId: {
          bsonType: "objectId"
        },
        status: {
          enum: [
            "draft",
            "placed",
            "completed",
            "cancelled"
          ]
        },
        items: {
          bsonType: "array",
          minItems: 1
        },
        createdAt: {
          bsonType: "date"
        }
      }
    }
  }
})

Database validation protects every writer, including scripts and tools that bypass application-level checks. Application schemas can still provide richer, more user-friendly validation errors; the two layers serve different purposes.

Validation is a database boundary, not a replacement for domain validation. The database can require an array and a date, while the application can explain why a particular order transition is not permitted.

Validation levels/actions

MongoDB validation settings control how broadly validation applies and what happens when a write does not satisfy it. Depending on the configuration, invalid writes can error or warn.

Use a strict production policy unless a migration scenario intentionally requires a staged rollout. If validation is relaxed temporarily, define how and when the collection will return to the intended enforcement level.

Schema migration

MongoDB's flexible schema does not eliminate migrations. Once documents exist in production, changing their shape still requires a plan.

For example, a version-one document might be:

javascript
{
  name: "Maya"
}

Version two might move the field and record the shape:

javascript
{
  profile: {
    name: "Maya"
  },
  schemaVersion: 2
}

Common strategies include:

Eager migration

Run a script that updates all documents to the new shape. This makes the target state clear, but the script must be planned for workload, failures, and resumability.

Lazy read migration

The application understands the old shape and upgrades a document when it is read or written. This can avoid a large one-time operation, but old documents can remain for a long time if they are rarely accessed.

Dual-read/write transition

Temporarily support both shapes while writes move documents toward the new representation. This is useful for staged deployments, but the transition needs an explicit end condition.

Do not leave an indefinitely ambiguous schema in place. Once every code path has to handle every historical shape, the system becomes difficult to reason about and debug.

Migration planning should include reads, writes, indexes, validators, and rollback behavior. A document update is only one part of a shape change if queries or validation rules also depend on old fields.

Schema version field

A document can record its shape explicitly:

javascript
{
  schemaVersion: 3
}

This is useful for complex, long-lived polymorphic migrations where the application needs to select the correct interpretation.

It is not required for every simple collection. Add it when it simplifies a real migration or interpretation problem, not as ceremony.

Multi-tenant modeling

A common shared-collection design stores the tenant on each document:

javascript
{
  tenantId: ObjectId(...),
  ...
}

Every tenant-scoped query must include the tenant constraint. Indexes usually begin with tenantId when that matches the query patterns:

javascript
{ tenantId: 1, createdAt: -1 }

Do not trust a tenantId supplied in a client request body. Derive tenant identity from the server-side authentication and authorization context, then use that trusted value for the query and write.

This is both a modeling and a security boundary. A correctly shaped query that uses an attacker-controlled tenant value can still return or modify another tenant's data.

Database per tenant

Using a separate database for each tenant is another option when strong isolation is important and the tenant count is small enough to operate comfortably.

The trade-offs include:

  • many databases;
  • migrations;
  • connections;
  • operations.

A collection per tenant is often operationally painful for similar reasons, especially as tenant count grows. Choose the tenant strategy intentionally based on isolation, scale, operational tooling, and query patterns.

Hot documents

A document updated extremely frequently can become a contention hotspot. For example:

javascript
{
  globalCounter: ...
}

can become a bottleneck at a very high write rate when every writer targets the same document.

The issue is not that the counter update is non-atomic. Atomicity protects correctness for an individual update; it does not make unlimited concurrent writes free or eliminate contention around one record.

Consider sharded counters, bucketing, or an event-oriented design. MongoDB supports atomic updates, but a single document still has finite write throughput and cannot absorb unlimited concurrent updates.

Arrays and indexes

Multikey indexes index array elements. Consequently, a document with a huge array can produce a large number of index entries.

Compound multikey rules and limitations also matter when arrays participate in compound indexes. Do not create giant arrays and assume that adding an index makes them free. The array affects document size, update cost, index size, and query behavior.

$lookup as design signal

MongoDB supports join-like queries through $lookup, and $lookup is a useful feature when the relationship and workload call for it.

However, if every primary query performs several complex lookups across heavily normalized collections, revisit the document model. That pattern may indicate that the data is being modeled as if it were relational without a reason to do so.

Do not ban $lookup. Treat frequent, expensive joins as a signal to re-examine the access patterns and document boundaries.

Transactions as design signal

Multi-document transactions are valid and sometimes necessary. They are not a failure of MongoDB modeling by themselves.

Still, if a basic aggregate update always needs eight documents, ask whether the aggregate boundary is too normalized. The question is not whether transactions are allowed; it is whether the chosen boundary makes ordinary work unnecessarily cross-document.

Again, this is a design question, not an absolute rule.

Data duplication consistency

When mutable data is duplicated, choose and document the consistency semantics. Common strategies are:

Synchronous

Update the canonical record and its duplicates in one transaction.

Eventual

Update the canonical record first, then let a background worker update projections. Readers may see old duplicated values briefly.

Snapshot

Do not update the historical duplicate at all. The old value is intentionally part of the record's history.

Document which semantics apply to each duplicated field. Otherwise, a future developer cannot tell whether a mismatch is a bug or an expected snapshot.

Consistency is part of the contract exposed by the read model. A caller that requires current profile data should not silently depend on a projection whose documented behavior is eventual.

Deletion

When a referenced document is deleted, MongoDB does not automatically enforce a foreign-key cascade. The application and its operational processes must define what happens to references.

Possible policies include:

  • restrict delete;
  • soft delete;
  • cascade;
  • allow orphans;
  • run a cleanup job.

Do not assume that referential integrity exists automatically. The chosen policy should be consistent with the domain and should be tested for partial failures.

Deletion also needs to account for authorization and retention. A cleanup job should not remove records that must remain available for audit, and a cascade should not cross a tenant boundary because of an unchecked reference.

Soft delete

A soft-deleted document can retain a deletion timestamp:

javascript
{
  deletedAt: ISODate(...)
}

Benefits include:

  • recovery/audit;

Costs include:

  • every query must exclude deleted documents;
  • unique indexes need a partial strategy;
  • data continues to grow;
  • accidental leakage is possible.

Use soft deletion when the domain needs recovery or historical visibility. It is not a harmless default, because every read, index, retention process, and authorization path must account for it.

Make the default scope explicit in repository code or query helpers, and make administrative views explicit when they include deleted records. Otherwise, a forgotten predicate can expose records that users believe are gone.

Audit history

Do not allow an audit history array to grow forever inside the main record:

javascript
history: [...]

Use a separate audit collection or event store instead. That keeps the primary document bounded and lets audit retention, querying, and access control be managed independently.

Access-pattern worksheet

For each collection, write down the workload before finalizing the schema:

text
Top reads:
Top writes:
Filter fields:
Sort fields:
Expected document size:
Expected array bounds:
Atomic update boundary:
Deletion behavior:
Tenant scope:
Retention:
Indexes:

Design the index and schema together. An elegant document shape that cannot support the real filters and sorts is not a finished design.

The worksheet is also useful during review. If nobody can state the top reads, update boundary, expected size, or retention behavior, the model is still based on abstract entities rather than on the workload.

Anti-patterns

Watch for these recurring problems:

  • one collection per tenant/user;
  • huge unbounded arrays;
  • arbitrary key/value mega-document;
  • relational schema copied 1:1 without reason;
  • embedding a canonical mutable object everywhere with no synchronization plan;
  • referencing everything solely because SQL did;
  • using a transaction for every basic write;
  • having no tenant field or index plan;
  • having no deletion or schema-evolution strategy.

Exercises

  1. Model an order aggregate.
  2. Model a user with millions of posts.
  3. Model student-course enrollment.
  4. Choose snapshot versus canonical duplication.
  5. Design a bounded recent-reviews subset.
  6. Design an audit history collection.
  7. Add tenant scoping.
  8. Write a collection validator.
  9. Plan a v1→v2 schema migration.
  10. Review a relational schema and intentionally remodel it for MongoDB access patterns.

For each exercise, explain the boundary you chose and the assumption that makes it safe. State the array bound, the source of truth for duplicated fields, the tenant constraint, and the index that supports the main read.

Mastery checklist

Explain:

  • access-pattern-first modeling;
  • embedding versus referencing;
  • bounded arrays;
  • denormalization;
  • the Subset, Computed, and Bucket patterns;
  • polymorphism;
  • validation;
  • migration;
  • multi-tenancy;
  • $lookup and transaction design signals;
  • deletion and soft-delete trade-offs.

Official references

Reader page: /mongodb/lesson/135/mongodb-data-modeling-access-patterns-embedding-referencing-schema-validation-and-anti-patterns