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.
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.
Related Concepts #
Embedding vs. referencing trade-offs, denormalization, populate() in Mongoose.
Related content
Preventing Duplicate Votes in a Polling API Under Concurrent Requests
TIL that a 'check if the user already voted, then write' guard is racy under concurrent requests — the fix was a unique compound index plus an atomic $inc, so MongoDB rejects the duplicate instead of the app trying to catch it first.
Debugging a MongoDB/WiredTiger Cache Pressure Issue in Production
TIL that intermittent MongoDB slowdowns that don't show up in query logs can be a WiredTiger cache eviction problem, not a query problem — and the fix was a resource limit, not an index.
Live Poll Results Without WebSockets: Server-Sent Events in a Node.js API
TIL that once vote writes were race-free, the results screen still felt dead until a manual refresh — switching from client polling to Server-Sent Events made results update instantly without the bidirectional complexity WebSockets would have added for a channel that only ever sends in one direction.