Warming up the neural circuits...
By the end of this chapter you will:
Every developer should build an e-commerce backend at least once. Not because you'll work at Amazon — because e-commerce exposes every hard backend problem in one project: concurrency (two people buying the last item), idempotency (customer clicks "Pay" twice), webhooks (Stripe telling you a payment succeeded), transactional emails (order confirmation, shipping update), and inventory management (reserving stock, releasing expired holds). This project distills those problems into a working system. By the end, you'll have a production-shaped e-commerce API that handles real money — or at least Stripe test-mode money — with proper concurrency control and idempotency guarantees.
POST /api/auth/register — Create user account
POST /api/auth/login — Login, returns JWT
GET /api/products — List products (paginated, filterable)
GET /api/products/:id — Get product detail
POST /api/cart/items — Add item to cart
PATCH /api/cart/items/:id — Update quantity
DELETE /api/cart/items/:id — Remove item from cart
GET /api/cart — Get current cart
POST /api/orders — Create order from cart
GET /api/orders — List user's orders
GET /api/orders/:id — Get order detail
POST /api/orders/:id/pay — Initiate payment (returns Stripe client secret)
POST /api/webhooks/stripe — Stripe webhook receiver-- Users
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Products
CREATE TABLE products (
ADR-1: PostgreSQL for everything (cart, orders, products). We're not adding Redis for carts yet. A single PostgreSQL instance simplifies operations. Add Redis when cart read/write volume exceeds 1000 req/s.
ADR-2: Stripe Payment Intents instead of Charges. Payment Intents support 3D Secure, payment methods, and webhook-based confirmation. Charges are legacy.
ADR-3: Idempotency keys for order creation. The client generates a unique key before creating an order. If the request times out and the client retries, the server returns the original response. This prevents double-charges.
ADR-4: SELECT ... FOR UPDATE for inventory deduction. When an order is placed, we lock the product rows to prevent overselling during concurrent checkouts.
ADR-5: Webhook-based payment confirmation. We don't trust the client to tell us payment succeeded. Stripe sends a webhook. Only after verifying the webhook signature do we update the order status.
ecommerce-backend/
├── src/
│ ├── config/
│ │ └── index.ts # Env vars, Stripe init, DB pool
│ ├── db/
│ │ ├── migrations/ # SQL migration files
│ │ │ ├── 001_users.sql
│ │ │ ├── 002_products.sql
│ │ │ ├── 003_cart_items.sql
│ │ │ ├── 004_orders.sql
│ │ │ └── 005_idempotency.sql
│ │ ├── pool.ts # pg Pool singleton
│ │ └── migrate.ts # Migration runner
│ ├── middleware/
│ │ ├── auth.ts # JWT verification middleware
│ │ ├── idempotency.ts # Idempotency key middleware
│ │ └── error-handler.ts # Global error handler
│ ├── modules/
│ │ ├── auth/
│ │ │ ├── auth.controller.ts
│ │ │ ├── auth.service.ts
│ │ │ └── auth.routes.ts
│ │ ├── products/
│ │ │ ├── products.controller.ts
│ │ │ ├── products.service.ts
│ │ │ └── products.routes.ts
│ │ ├── cart/
│ │ │ ├── cart.controller.ts
│ │ │ ├── cart.service.ts
│ │ │ └── cart.routes.ts
│ │ ├── orders/
│ │ │ ├── orders.controller.ts
│ │ │ ├── orders.service.ts
│ │ │ └── orders.routes.ts
│ │ └── webhooks/
│ │ ├── stripe.controller.ts
│ │ ├── stripe.service.ts
│ │ └── stripe.routes.ts
GET /api/products — cursor , filtering by category, sorting by price/nameGET /api/products/:id — single product with full detailsPOST /api/cart/items — add item (upsert if already in cart)PATCH /api/cart/items/:id — update quantity (delete if quantity = 0)DELETE /api/cart/items/:id — remove itemGET /api/cart — return cart with product details joinedPOST /api/orders — create order from cart with idempotency keySELECT ... FOR UPDATE inventory deductionPOST /api/webhooks/stripe — verify Stripe signaturepayment_intent.succeeded: update order status → paid, send confirmation emailpayment_intent.payment_failed: update order status → cancelled, release inventoryGET /api/orders — list user's orders (cursor paginated)GET /api/orders/:id — order detail with itemsGET /health)SELECT ... FOR UPDATEThis is the most critical piece of code in the project. Get it wrong, and you'll oversell products:
async function createOrder(
userId: string,
idempotencyKey: string,
client: PoolClient
): Promise<Order> {
// 1. Get cart items
const cartItems = await client.query(
The FOR UPDATE lock means: if two users try to buy the last item simultaneously, the second waits until the first commits (or rolls back). If the first transaction deducts the last item, the second transaction sees inventory_count = 0 and throws a 409 error. No overselling.
FOR UPDATE locks rows for the duration of the transaction. Don't make HTTP calls (Stripe API, email) inside the transaction. Create the order, COMMIT, then call Stripe and send emails. Long-held locks are a performance killer.
Idempotency prevents double-charges when a client retries a timed-out request:
// middleware/idempotency.ts
import { Request, Response, NextFunction } from 'express';
import { db } from '../db/pool';
export async function idempotencyMiddleware(
req: Request, res:
The client should generate a unique key per unique operation — typically a UUID v4. The same key should be used for retries of the same operation. Stripe's API uses this pattern: pass Idempotency-Key header, and Stripe returns the same response for duplicate keys within 24 hours.
Never trust that a webhook came from Stripe. Always verify the signature:
// modules/webhooks/stripe.controller.ts
import { Request, Response } from 'express';
import Stripe from 'stripe';
const stripe = new Stripe(config.stripeSecretKey);
export async function
Critical detail: req.body must be the raw, unparsed request body. Express's express.json() middleware parses the body, which invalidates the signature. Use express.raw({ type: 'application/json' }) for the webhook route specifically.
| Mistake | Why it's wrong | What to do instead |
|---|---|---|
Not using FOR UPDATE for inventory | Two concurrent checkouts can both read inventory_count = 1 and both deduct, resulting in -1 inventory | Lock product rows with SELECT ... FOR UPDATE inside a transaction |
| Making Stripe API calls inside the database transaction | The transaction holds locks; a slow Stripe API call (5s timeout) blocks all other checkouts | Commit the database transaction first, THEN call Stripe. If Stripe fails, update the order status to 'payment_failed' |
| Trusting the client to confirm payment | A malicious client can call your "payment successful" endpoint without ever paying | Only update order status from the Stripe webhook (server-to-server, signature-verified) |
| Not storing price snapshots in order_items | If you join to products.price_cents and the price changes later, historical orders show wrong totals | Store product_name and price_cents as snapshots in order_items at purchase time |
| Using the same idempotency key for different operations | A key like create-order means a user can only ever create one order | Client generates a unique key per attempt: create-order-{uuid}. Key uniqueness = operation uniqueness. |
| Parsing the webhook body before signature verification | transforms the raw body; the signature is computed on the raw bytes — verification will fail |
Q: Why use SELECT ... FOR UPDATE instead of an inventory_count >= quantity check in a WHERE clause?
A: The WHERE clause check happens at the moment of the SELECT. Between the SELECT and the UPDATE, another transaction could also pass the check and deduct the inventory. FOR UPDATE locks the rows — no other transaction can read or write them until this transaction commits. This serializes inventory access and guarantees correctness under concurrency.
Q: Why use idempotency keys instead of disabling the "Place Order" ? A: Disabling the button prevents double-clicks, but doesn't prevent: network retries (the request succeeded but the response was lost), browser refresh on the confirmation page, or API clients retrying after a timeout. Idempotency keys handle all these cases at the server level. Client-side prevention is UX; server-side idempotency is correctness.
Q: Why process payments via webhooks instead of the synchronous API response?
A: Some payment methods (3D Secure, bank transfers, OXXO) don't complete synchronously. The PaymentIntent might be processing for hours or days. The webhook is the only reliable way to know the final state. Even for cards (which usually complete instantly), edge cases (bank timeout, fraud review) can delay the result. Always design for the async case.
This project covers the hard parts of e-commerce: concurrency-safe inventory deduction with SELECT ... FOR UPDATE, idempotent order creation to prevent double-charges, Stripe Payment Intents with webhook-based confirmation, and price snapshots for historical accuracy. The architecture is deliberately simple (PostgreSQL for everything, Express for HTTP, Stripe SDK for payments) — the complexity is in the correctness guarantees, not the infrastructure.
SELECT ... FOR UPDATE serializes access to rows — essential for inventory deduction.order_items prevent historical orders from changing when product prices change.Use express.raw({ type: 'application/json' }) for the webhook route. Verify signature on raw body. |
| Not handling webhook retries | Stripe retries webhooks for up to 3 days. Your handler must be | Check if the event was already processed (by stripe_event_id) before processing |