>_devkit
mongodb-production
skills/mongodb-production/

references/data-modeling.md

Data modeling

Start from the queries

Write this table before designing anything. It is the whole design process.

QueryFrequencyLatency budgetFields returned
List vehicles for the catalogue, newest firstvery high< 100mscard fields only
Vehicle detail by idhigh< 100mseverything
A user's bids across auctionsmedium< 300msbid + auction summary
Admin: settle auctions due nowlow, scheduledseconds is finefull documents

Two things fall straight out of it. Anything read together on a hot path wants to be in one document. Anything only an admin touches once an hour does not deserve a denormalized copy that every write has to maintain.

Embed or reference

The full decision, restated with the reasoning:

Embed when the data is read with its parent, is bounded, is owned exclusively by the parent, and is written in the same operation. A user's addresses, an order's line items, a product's dimensions. One read gets everything, one atomic write updates it, no join.

Reference when it grows without limit, is queried on its own, is shared, or changes independently. Orders for a customer, messages in a conversation, audit events, tags shared across products.

The bounded test

Ask: what is the maximum, and who enforces it?

"A user has at most about 5 addresses, and the UI caps it at 10" is bounded. "A post has comments" is not, and no amount of optimism makes it so.

The 16MB document limit is a hard ceiling, but treat it as a disaster line, not a budget. Trouble starts far earlier:

  • The whole document is read and rewritten on every update, so a 2MB document

makes a one-field update expensive.

  • Large documents crowd the working set out of RAM, and MongoDB's performance

cliff is the moment the working set stops fitting.

  • Indexes over large arrays grow correspondingly.

A practical target: keep the common documents well under 100KB.

When both are true

Sometimes the data is read with the parent and unbounded. That is what the subset pattern is for: embed the few that matter, reference the rest.

// product document
{
  _id: ...,
  name: "Hilux",
  reviewCount: 1284,              // computed
  averageRating: 4.6,             // computed
  recentReviews: [ {...}, {...} ] // last 3, embedded for the detail page
}
// full reviews live in their own collection, queried on demand

The product page renders from one document. "See all reviews" pages a second collection. This is the single most useful pattern in the catalogue.

The patterns worth knowing

Subset. Embed the hot slice, reference the tail. As above.

Computed. Store the aggregate rather than recomputing it. reviewCount, totalMinor, bidCount. Reads dominate writes in almost every product, so pay at write time. Accept that it can drift, and have a reconciliation job.

Bucket. For time series and high-frequency events, group many readings into one document per time window instead of one document per reading. Cuts document count and index size by orders of magnitude.

{ sensorId: "a1", day: ISODate("2026-07-23"), readings: [ {t, v}, ... ] }

Extended reference. Copy the two or three fields you actually display alongside the reference, so the common read needs no join.

{ orderId: ..., buyer: { _id: ..., name: "Kwame", email: "..." } }

Duplicated data must have an owner and an update path. Denormalize the fields that rarely change (a name), not the ones that change constantly (a balance).

Schema versioning. A schemaVersion field on every document. This is what makes evolution possible without downtime: read code handles versions N and N-1, a background job migrates, then support for N-1 is dropped.

Outlier. When 99% of documents are small and a handful are enormous, give the outliers a flag and a spill-over collection rather than modeling everything for the worst case.

Polymorphic. Different shapes in one collection with a type discriminator, when they are queried together. A single transactions collection with type: "deposit" | "withdrawal" | "fee" beats three collections you constantly have to union.

Field conventions

_id. Use it. It is indexed, unique, and required, so a separate id field duplicates identity and costs a second index on every collection. If you need an external, human-facing identifier (an order number, a public slug), that is a different field with a different purpose; do not mirror _id into id out of habit.

ObjectIds embed a timestamp and are therefore roughly monotonic. Convenient for "newest first" without a separate field; a hazard as a shard key.

Money as integers in minor units. priceMinor: 150000 for 1,500.00. Floating point currency produces rounding disputes that are painful to unwind after the fact. Decimal128 is the alternative when you genuinely need decimal arithmetic in the database.

Dates as BSON Date, never strings. String dates sort correctly only by accident and cannot use date operators.

Enums as short strings, not integers. status: "paid" is greppable in logs and self-documenting in a shell; status: 3 requires a lookup table that lives in someone's head.

Nulls. Prefer omitting a field to storing null, unless you need to distinguish "not set" from "explicitly cleared". Sparse and partial indexes work on absence.

Schema validation

MongoDB being schemaless is a property of the engine, not a design goal. Declare a validator on every collection:

db.createCollection("vehicles", {
  validator: { $jsonSchema: {
    bsonType: "object",
    required: ["tenantId", "schemaVersion", "make", "priceMinor", "status"],
    properties: {
      tenantId:      { bsonType: "objectId" },
      schemaVersion: { bsonType: "int" },
      priceMinor:    { bsonType: "long", minimum: 0 },
      status:        { enum: ["draft", "listed", "sold", "archived"] }
    }
  }},
  validationLevel: "moderate",   // existing documents are not retro-validated
  validationAction: "error"
})

Without this, one buggy deploy writes priceMinor as a string in 3% of documents, and every consumer downstream grows defensive code forever. The validator turns that into a failed write at the source.

validationLevel: "moderate" applies to inserts and to updates of already-valid documents, which is what lets you add validation to a live collection without breaking on legacy rows.

Transactions

Multi-document transactions exist and work, and are the wrong first reach. A correctly embedded document gives atomicity for free, since a single document update is always atomic.

Use transactions when a genuine invariant spans collections: moving money between two accounts, allocating stock while creating an order. Keep them short. They hold resources, are subject to a time limit, and can fail on write conflict, so the calling code needs a retry path.

If you find yourself wanting transactions everywhere, the model is too normalized.

Migrations

Data migrations need the same discipline as schema migrations in SQL, plus one extra constraint: there is no schema to change, only documents to rewrite.

  • Version them, keep them in the repo, run them once as a job rather than

from application startup.

  • Make them idempotent and resumable. A migration over ten million documents

will be interrupted.

  • Deploy backward compatible for one release: write both shapes, backfill, then

read only the new shape, then stop writing the old one.

  • Never rewrite a large collection in one pass without batching and a delay.

It will saturate the primary and take the app down with it.