E-commerce API
Difficulty: Beginner–Intermediate · Builds on: routing, MongoDB & Mongoose, validation
You are building the backend for a small online store. Customers browse products, place an order with one or more items, and check their order history. No cart, no payments, no auth — just the core data model and the two or three rules every real store depends on.
The point of this exercise is designing the API from a data model first. Read the ERD, translate it into Mongoose schemas, and only then write routes.
The Data Model (ERD)
How to read it:
- A customer places many orders; an order belongs to exactly one customer.
- An order contains one or more order items — an order with zero items is invalid.
- A product can appear in many order items across many orders.
ORDER_ITEM.priceAtPurchaseis a snapshot of the product price at the moment of purchase. If the catalog price changes next week, old orders must still show what the customer actually paid.
In MongoDB, order items don't need their own collection — they are embedded in the order document, which matches how they're always read (you never fetch an order item without its order).
What You'll Practice
- Translating an ERD into Mongoose schemas — references vs embedded documents
- Computing money on the server: the client sends product IDs and quantities, never prices
- Keeping stock consistent when orders are placed
- Snapshotting data (
priceAtPurchase) instead of referencing it - Basic input validation and consistent error responses
Requirements
- Product prices and order totals are always calculated on the server — a request body containing a price or total is ignored
- Placing an order checks stock for every item first; if any item has insufficient stock, the whole order is rejected and nothing is decremented
- A successful order decrements stock for each product
- Each order item stores
priceAtPurchasecopied from the product at order time - An order must contain at least one item; quantities must be positive integers
- Unknown product IDs in an order return
404with the offending ID
API Contract
| Method | Endpoint | Request | Success Response |
|---|---|---|---|
| POST | /api/products | Body: name, price, stock, category | 201 — created product |
| GET | /api/products | Optional query: category | 200 — array of products |
| GET | /api/products/:id | — | 200 — one product, 404 if missing |
| POST | /api/orders | Body: customer (name, email), items array | 201 — order with computed total |
| GET | /api/orders/:id | — | 200 — order with populated products |
| GET | /api/orders | Query: email | 200 — that customer's orders, newest first |
Edge Cases to Handle
| Request | Expected result |
|---|---|
Order with an empty items array | 400 — at least one item is required |
Order with quantity: 0 or quantity: -2 | 400 — quantity must be a positive integer |
| Order for 5 units when stock is 3 | 400 — insufficient stock, order not created |
| Order with a made-up product ID | 404 — product not found |
Order body that includes total: 1 | The field is ignored; the server computes the real total |
GET /api/orders without an email query | 400 — email is required |
Build Guide
Step 1 — Project setup
Nothing new here: npm install express mongoose, an express.json() app with /api/products and /api/orders routers, a central error handler, and a Mongoose connection — the same skeleton as the Blog API.
Step 2 — Models straight from the ERD
Each entity box becomes a schema. ORDER_ITEM becomes an embedded array inside Order — it has no life of its own.
import mongoose from "mongoose";
const productSchema = new mongoose.Schema(
{
name: { type: String, required: true, trim: true, maxlength: 120 },
price: { type: Number, required: true, min: 0 },
stock: { type: Number, required: true, min: 0 },
category: { type: String, trim: true, lowercase: true },
},
{ timestamps: true },
);
productSchema.index({ category: 1 });
export default mongoose.model("Product", productSchema);
import mongoose from "mongoose";
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, min: 0 },
},
{ _id: false },
);
const orderSchema = new mongoose.Schema(
{
customer: {
name: { type: String, required: true, trim: true },
email: { type: String, required: true, trim: true, lowercase: true },
},
items: {
type: [orderItemSchema],
validate: [(items) => items.length > 0, "Order must contain at least one item"],
},
total: { type: Number, required: true, min: 0 },
status: { type: String, enum: ["pending", "shipped", "delivered"], default: "pending" },
},
{ timestamps: true },
);
orderSchema.index({ "customer.email": 1, createdAt: -1 });
export default mongoose.model("Order", orderSchema);
The customer is embedded here to keep the exercise small. Promoting it to its own collection with a ref — as in the ERD — is the first stretch goal.
Step 3 — Product routes
The three product endpoints are plain CRUD — the same shape you built in the Task Manager, so no reference code here. Two things to carry over:
- Destructure exactly
{ name, price, stock, category }from the body — that acts as a field allowlist - Guard
/:idwithmongoose.isValidObjectIdand return404when nothing matches
Step 4 — Placing an order
This is the heart of the exercise, and it's yours to write. POST /api/orders should follow this exact sequence:
- Validate the shape — customer name and email present,
itemsa non-empty array, everyproductIda valid ObjectId, everyquantitya positive integer. Reject with400before touching the database. - Load all referenced products in one query —
Product.find({ _id: { $in: [...] } }), then index them in aMapby id. Any missing id is a404. - Check stock for every item before writing anything — if the third item fails, no order should exist and no stock should have moved.
- Build the order items on the server — copy
product.priceintopriceAtPurchaseand sumpriceAtPurchase * quantityintototal. Whatever prices or totals the client sent are ignored. - Create the order, then decrement stock —
$inc: { stock: -quantity }for each product.
The two rules that make this a real store instead of a toy:
- The client never sends prices.
priceAtPurchaseandtotalcome from the database at the moment of ordering. Any store that trusts a client-supplied total will eventually sell laptops for one rupee. - Check everything, then write. True atomicity under concurrent orders needs a MongoDB transaction — that's a stretch goal.
The remaining two endpoints are simple reads: GET /api/orders/:id with .populate("items.product", "name category"), and GET /api/orders?email= sorted newest first (the compound index from Step 2 serves exactly this query).
Test It with curl
curl -s -X POST http://localhost:3000/api/products \
-H "Content-Type: application/json" \
-d '{"name": "Mechanical Keyboard", "price": 90, "stock": 10, "category": "accessories"}'
curl -s -X POST http://localhost:3000/api/products \
-H "Content-Type: application/json" \
-d '{"name": "USB-C Hub", "price": 35, "stock": 3, "category": "accessories"}'
curl -s -X POST http://localhost:3000/api/orders \
-H "Content-Type: application/json" \
-d '{
"customer": { "name": "Hafsa", "email": "hafsa@example.com" },
"items": [
{ "productId": "KEYBOARD_ID", "quantity": 2 },
{ "productId": "HUB_ID", "quantity": 1 }
]
}'
The response total should be 215 (2 × 90 + 1 × 35), and the hub's stock should now read 2.
# more than the remaining stock -> 400, nothing decremented
curl -s -X POST http://localhost:3000/api/orders \
-H "Content-Type: application/json" \
-d '{"customer": {"name": "Ibrahim", "email": "ibrahim@example.com"}, "items": [{"productId": "HUB_ID", "quantity": 5}]}'
# order history
curl -s "http://localhost:3000/api/orders?email=hafsa@example.com"
Stretch Goals
- Promote Customer to its own collection as the ERD shows — a
Customermodel, orders holding aref, andGET /api/customers/:id/orders - Wrap order placement in a MongoDB transaction so stock checks and decrements are atomic under concurrent requests
- Add auth by bolting on the Authentication API — customers see only their own orders, and only admins create products
- Add pagination and category filtering to the product list using the patterns from the Blog API