Designing MongoDB Schemas with Mongoose Without Fighting Yourself Later

TIL that a MongoDB collection modeled around how a screen displayed data instead of how the data actually related caused a rewrite once a second screen needed the same data differently.

3 min read

Sabin Shrestha

Full-Stack Developer — Next.js, React & React Native

The Problem #

An early orders schema embedded the full customer object inside every order document, because that's what the order-detail screen needed and embedding made that one query fast. Then the dispatch dashboard needed "all orders for this customer," and there was no clean way to query that without scanning every order and matching on an embedded, potentially stale copy of the customer.

Context #

MongoDB's flexibility makes it easy to model a schema around a single screen's needs, which feels productive right up until a second screen needs the same data shaped differently.

What I Tried #

Adding a denormalized customerId field alongside the embedded customer object, updating both on customer edits, and querying by customerId for the dispatch view.

What Went Wrong #

Now there were two sources of truth for customer data on every order, and a customer edit that missed updating the embedded copy silently left stale data on old orders — which nobody noticed until a customer called about a wrong name on their delivery.

The Solution #

Referenced the customer by ID and only embedded the small subset of fields that were genuinely immutable snapshots of that moment (like the delivery address at order time, which should stay fixed even if the customer later updates their address).

const orderSchema = new Schema({
  customer: { type: Schema.Types.ObjectId, ref: "Customer", required: true },
  // A deliberate snapshot, not denormalization for convenience —
  // this address should NOT change if the customer's profile does.
  deliveryAddressAtOrderTime: { type: String, required: true },
});

Why It Works #

The rule that made this stick: embed a field only when it represents a fact that's true forever about that specific document (a historical snapshot), and reference everything that's meant to reflect current state. Mixing the two — embedding "current" data for query convenience — is what created the stale-copy bug.

Lessons Learned #

MongoDB not enforcing a schema doesn't mean the data doesn't have a real shape. It just means the consequences of getting that shape wrong show up later, in a second query pattern you didn't design for, instead of at write time.

What I Would Do Differently #

I'd write down which fields on a document are "current state" vs. "point-in-time snapshot" before choosing embed vs. reference, instead of picking based on which query I happened to be optimizing for that day.

Embedding vs. referencing trade-offs, denormalization, populate() in Mongoose.