Overview
The Blog with Embedded Comments project chose embedding because comments belong to exactly one post and are never useful anywhere else. A product on an e-commerce order is the opposite case: the same product is shared across potentially thousands of orders, its price and stock change independently of any single order, and an order needs to know what a product looked like without owning a private copy of it. Embedding a full product document into every order that contains it would mean duplicating the same product data thousands of times over, and updating a product's description would require rewriting every order that ever referenced it — that is exactly the kind of shared, mutable data referencing exists for.
This project builds that referenced design with Mongoose: a `Product` model and an `Order` model where each order line item stores a `product` field holding the referenced product's `ObjectId`, not the product itself. On top of the schema, it builds the operation that referencing makes trickier than embedding: creating an order has to decrement stock on one or more products and insert the order document, and both of those writes need to succeed or fail together — which is exactly what a MongoDB multi-document transaction is for.
- A Mongoose `Product` schema with `price` and `stock` fields.
- A Mongoose `Order` schema whose line items reference products by `ObjectId`, not by embedding them.
- An Express `POST /orders` route that creates an order from a cart of product IDs and quantities.
- A `session.startTransaction()` block that decrements stock and inserts the order atomically.
- A rollback path that aborts the whole order if any product is out of stock.
Prerequisites
- Basic CRUD — `insertOne`, `find`, `updateOne` at a glance.
- What a document and a collection are, and the idea of an `ObjectId` as a document's identity.
- Basic Mongoose — `new mongoose.Schema({...})` and `mongoose.model(...)`.
- Basic Express — defining a route handler with `app.post(path, handler)` and reading `req.body`.
- The general idea of a transaction — a group of writes that should all succeed or all fail together.
Project Structure
Two collections: `products`, one document per catalog item, and `orders`, one document per order, whose `items` array holds `{ product, quantity, priceAtPurchase }` line items. `product` is an `ObjectId` pointing back at `products` — a reference, not an embedded copy — while `priceAtPurchase` is deliberately duplicated onto the order anyway, as a snapshot of what the customer actually paid, the same reasoning `order_items.unit_price` uses in the MySQL E-commerce Schema project.
// A representative product document{ "_id": ObjectId("64f2b1a2c4d5e6f7a8b9c0e1"), "name": "Mechanical Keyboard", "sku": "SKU-KEYB-01", "price": 79.99, "stock": 40}
// A representative order document — items reference products by ObjectId, they don't embed them{ "_id": ObjectId("64f2b1a2c4d5e6f7a8b9c0f1"), "customerEmail": "neha.kapoor@example.com", "status": "pending", "items": [ { "product": ObjectId("64f2b1a2c4d5e6f7a8b9c0e1"), "quantity": 1, "priceAtPurchase": 79.99 } ], "createdAt": ISODate("2026-08-01T12:00:00Z")}Step 1: Define the Product Schema
`stock` gets a `min: 0` validator for the same reason the MySQL projects used a `CHECK` constraint: it stops the field from ever being written with a negative value, no matter what application code eventually decrements it. Nothing in this schema references `orders` — a product has no idea which orders it appears on, which is exactly what makes it safe to sell the same product on any number of orders without touching this document at all.
const mongoose = require('mongoose');
const productSchema = new mongoose.Schema({ name: { type: String, required: true }, // Display name shown in the catalog sku: { type: String, required: true, unique: true }, // Human-facing catalog code — must be unique price: { type: Number, required: true, min: 0 }, // Current price; orders snapshot this, they don't read it live stock: { type: Number, required: true, min: 0, default: 0 }, // Can never go negative — mirrors a SQL CHECK constraint});
const Product = mongoose.model('Product', productSchema);module.exports = { Product };Step 2: Define the Order Schema
`product: { type: mongoose.Schema.Types.ObjectId, ref: 'Product' }` is the reference itself — it stores only the product's ID inline, and the `ref` option tells Mongoose which model `.populate('items.product')` should look in if the full product document is ever needed later. `priceAtPurchase` is stored alongside that reference rather than looked up live, for the same reason `order_items.unit_price` was in the MySQL project: if `Product.price` changes next month, every past order must still show what the customer actually paid.
const mongoose = require('mongoose');
const orderItemSchema = new mongoose.Schema({ product: { type: mongoose.Schema.Types.ObjectId, ref: 'Product', required: true }, // Reference, not an embedded copy quantity: { type: Number, required: true, min: 1 }, priceAtPurchase: { type: Number, required: true }, // Snapshot of Product.price at order time, not a live lookup}, { _id: false }); // Line items don't need their own top-level _id — they're only ever accessed through their order
const orderSchema = new mongoose.Schema({ customerEmail: { type: String, required: true }, status: { type: String, enum: ['pending', 'shipped', 'delivered', 'cancelled'], default: 'pending' }, items: { type: [orderItemSchema], required: true }, // Array of line items, each referencing a product by ObjectId createdAt: { type: Date, default: Date.now },});
const Order = mongoose.model('Order', orderSchema);module.exports = { Order };The Blog with Embedded Comments project embedded comments because they belong to one post and are never queried alone. Here it is the opposite on every count: one product is shared across many orders, `price`/`stock` mutate independently of any order, and an order needs a frozen snapshot — not a live copy — of what it bought. Reference when data is shared and mutable; embed when it is owned and read together.
Step 3: Seed Some Products
Two products to build the order against — one with healthy stock, and one deliberately low, so the transaction's insufficient-stock rollback path can be demonstrated in Step 5.
await Product.insertMany([ { name: 'Mechanical Keyboard', sku: 'SKU-KEYB-01', price: 79.99, stock: 40 }, { name: 'Wireless Mouse', sku: 'SKU-MOUSE-01', price: 29.99, stock: 2 }, // Low stock on purpose, for Step 5]);Step 4: An Express Route That Creates an Order
This first pass looks up each product's current price, builds the order's `items` array with a `priceAtPurchase` snapshot, and inserts the order — but it does not yet touch `stock`, and it does not yet guard against two customers buying the last unit of the same product at the same moment. Both gaps get closed in Step 5.
const express = require('express');const router = express.Router();
router.post('/orders', async (req, res) => { const { customerEmail, cart } = req.body; // cart: [{ productId, quantity }, ...]
const items = []; for (const { productId, quantity } of cart) { const product = await Product.findById(productId); // Look up current price for the snapshot below if (!product) return res.status(404).json({ error: `Product ${productId} not found` }); items.push({ product: product._id, quantity, priceAtPurchase: product.price }); // Snapshot, not a live reference }
const order = await Order.create({ customerEmail, items }); res.status(201).json(order);});
module.exports = router;Step 5: Wrap the Order in a Transaction
Creating an order really means two things happening together: a `Product.stock` decrement for every line item, and a new `Order` document being inserted — and both must succeed or neither should happen, exactly the same problem the MySQL Banking Ledger project solved with `START TRANSACTION`/`COMMIT`/`ROLLBACK`. MongoDB's equivalent is a client session: `session.startTransaction()` begins it, every write inside the block passes `{ session }` so it becomes part of that transaction, `session.commitTransaction()` makes everything permanent together, and `session.abortTransaction()` undoes everything cleanly the moment a product turns out to be out of stock.
router.post('/orders', async (req, res) => { const { customerEmail, cart } = req.body; // cart: [{ productId, quantity }, ...] const session = await mongoose.startSession();
try { session.startTransaction(); // Everything from here to commitTransaction() happens as one atomic unit
const items = []; for (const { productId, quantity } of cart) { // $inc with a negative value decrements stock; the query filter stock >= quantity // makes the update itself the stock check — it simply matches nothing if stock is too low, // which avoids a separate read-then-write race between two concurrent orders. const product = await Product.findOneAndUpdate( { _id: productId, stock: { $gte: quantity } }, { $inc: { stock: -quantity } }, { new: true, session } );
if (!product) { await session.abortTransaction(); // Roll back: nothing written so far in this transaction takes effect session.endSession(); return res.status(409).json({ error: `Insufficient stock for product ${productId}` }); }
items.push({ product: product._id, quantity, priceAtPurchase: product.price }); }
const [order] = await Order.create([{ customerEmail, items }], { session }); // Array form required for a session insert await session.commitTransaction(); // Both stock decrements and the order insert become permanent together session.endSession();
res.status(201).json(order); } catch (err) { await session.abortTransaction(); // Any unexpected error also rolls back the whole transaction session.endSession(); res.status(500).json({ error: 'Order creation failed' }); }});Click Run to see what this code prints.
Click Run to see what this code prints.
Multi-document transactions only work against a MongoDB replica set (or a sharded cluster) — a single standalone `mongod` cannot run `startTransaction()`. MongoDB Atlas's free tier is a replica set by default, so this works out of the box there.
Complete Code
The full schema and route, assembled from the steps above.
// models.jsconst mongoose = require('mongoose');
const productSchema = new mongoose.Schema({ name: { type: String, required: true }, sku: { type: String, required: true, unique: true }, price: { type: Number, required: true, min: 0 }, stock: { type: Number, required: true, min: 0, default: 0 },});
const orderItemSchema = new mongoose.Schema({ product: { type: mongoose.Schema.Types.ObjectId, ref: 'Product', required: true }, quantity: { type: Number, required: true, min: 1 }, priceAtPurchase: { type: Number, required: true },}, { _id: false });
const orderSchema = new mongoose.Schema({ customerEmail: { type: String, required: true }, status: { type: String, enum: ['pending', 'shipped', 'delivered', 'cancelled'], default: 'pending' }, items: { type: [orderItemSchema], required: true }, createdAt: { type: Date, default: Date.now },});
const Product = mongoose.model('Product', productSchema);const Order = mongoose.model('Order', orderSchema);module.exports = { Product, Order };
// routes/orders.jsconst express = require('express');const mongoose = require('mongoose');const { Product, Order } = require('../models');const router = express.Router();
router.post('/orders', async (req, res) => { const { customerEmail, cart } = req.body; const session = await mongoose.startSession();
try { session.startTransaction(); const items = []; for (const { productId, quantity } of cart) { const product = await Product.findOneAndUpdate( { _id: productId, stock: { $gte: quantity } }, { $inc: { stock: -quantity } }, { new: true, session } ); if (!product) { await session.abortTransaction(); session.endSession(); return res.status(409).json({ error: `Insufficient stock for product ${productId}` }); } items.push({ product: product._id, quantity, priceAtPurchase: product.price }); } const [order] = await Order.create([{ customerEmail, items }], { session }); await session.commitTransaction(); session.endSession(); res.status(201).json(order); } catch (err) { await session.abortTransaction(); session.endSession(); res.status(500).json({ error: 'Order creation failed' }); }});
module.exports = router;Sample Queries
Two more operations a real storefront runs constantly: resolving an order's referenced products into their full details, and checking which products are running low.
// Resolve an order's item references into full product documents, Mongoose-sideconst order = await Order.findById(orderId).populate('items.product'); // populate() follows the ref back to Product{ "_id": "64f2b1a2c4d5e6f7a8b9c0f1", "customerEmail": "neha.kapoor@example.com", "items": [ { "product": { "_id": "64f2b1a2c4d5e6f7a8b9c0e1", "name": "Mechanical Keyboard", "sku": "SKU-KEYB-01" }, "quantity": 1, "priceAtPurchase": 79.99 } ]}// Products at or below a restock thresholddb.products.find({ stock: { $lte: 5 } }).sort({ stock: 1 });| name | sku | stock |
|---|---|---|
| Wireless Mouse | SKU-MOUSE-01 | 2 |
Extend This Project
- Add a `Category` model and a `category` reference on `Product` for catalog browsing.
- Add a `PATCH /orders/:id/cancel` route that restores stock inside its own transaction.
- Add optimistic concurrency (a `__v` version check) as an alternative to the `$gte` stock-guard pattern.
- Add a compound index on `{ customerEmail: 1, createdAt: -1 }` for a customer order-history page.
- Add a `reservedStock` field and reserve-then-confirm flow instead of decrementing stock immediately.
Summary
You referenced products from orders by `ObjectId` instead of embedding them, because products are shared across many orders and mutate independently — the opposite tradeoff from the embedded comments project, and the right one whenever child data is shared, mutable, or queried on its own. The transaction in Step 5 made "decrement stock, then insert the order" behave as one atomic unit, with `abortTransaction()` cleanly undoing a partially-applied stock decrement the moment any product in the cart turned out to be unavailable.