Skip to main content

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.priceAtPurchase is 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 priceAtPurchase copied 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 404 with the offending ID

API Contract

MethodEndpointRequestSuccess Response
POST/api/productsBody: name, price, stock, category201 — created product
GET/api/productsOptional query: category200 — array of products
GET/api/products/:id200 — one product, 404 if missing
POST/api/ordersBody: customer (name, email), items array201 — order with computed total
GET/api/orders/:id200 — order with populated products
GET/api/ordersQuery: email200 — that customer's orders, newest first

Edge Cases to Handle

RequestExpected result
Order with an empty items array400 — at least one item is required
Order with quantity: 0 or quantity: -2400 — quantity must be a positive integer
Order for 5 units when stock is 3400 — insufficient stock, order not created
Order with a made-up product ID404 — product not found
Order body that includes total: 1The field is ignored; the server computes the real total
GET /api/orders without an email query400 — 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.

models/product.js
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);
models/order.js
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 /:id with mongoose.isValidObjectId and return 404 when 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:

  1. Validate the shape — customer name and email present, items a non-empty array, every productId a valid ObjectId, every quantity a positive integer. Reject with 400 before touching the database.
  2. Load all referenced products in one queryProduct.find({ _id: { $in: [...] } }), then index them in a Map by id. Any missing id is a 404.
  3. Check stock for every item before writing anything — if the third item fails, no order should exist and no stock should have moved.
  4. Build the order items on the server — copy product.price into priceAtPurchase and sum priceAtPurchase * quantity into total. Whatever prices or totals the client sent are ignored.
  5. 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. priceAtPurchase and total come 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

Seed two products
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"}'
Place an order (use real IDs from the responses above)
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.

Failure paths
# 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

  1. Promote Customer to its own collection as the ERD shows — a Customer model, orders holding a ref, and GET /api/customers/:id/orders
  2. Wrap order placement in a MongoDB transaction so stock checks and decrements are atomic under concurrent requests
  3. Add auth by bolting on the Authentication API — customers see only their own orders, and only admins create products
  4. Add pagination and category filtering to the product list using the patterns from the Blog API