Production Production
Capstone: ShopFlow
The final project. Plan and build ShopFlow, a small online shop, with ZudoJS. A complete, runnable core for accounts, products and a checkout that never oversells, followed by milestones with acceptance criteria that take it to a modular monolith and then to services.
The brief
ShopFlow is an online shop. You have met it in pieces throughout this part of the course. Now you build it as one application, the way you would at work: from a brief, with a plan, in steps that each end with something that runs and is tested.
| Area | What ShopFlow must do |
|---|---|
| Users | Register, log in, log out, stay logged in with a session. Sign-in with Google or GitHub is optional. |
| Products | Admins create and edit products, sort them into categories and manage the stock. Everyone can browse. |
| Orders | Customers fill a cart and check out. A checkout takes the stock and creates the order in one transaction, and the shop never sells more than it has, even when two customers buy the last item at the same moment. |
| Administration | Roles and permissions decide who may do what. Every important action leaves an audit event. |
| Infrastructure | PostgreSQL, a cache, a queue for e-mails, scheduled jobs, events, an OpenAPI description, tests, logs, metrics and traces. |
This lesson gives you two things. First, a complete, runnable core: accounts, products and the checkout, built from ZudoJS packages, with tests. Every file below runs as it is. Second, milestones with acceptance criteria for everything else, so you can finish ShopFlow on your own and know when each part is done.
The architecture, and how it grows
ShopFlow follows the path from the modular monolith lesson and the microservices lesson, in three stages. You build stage 1 today.
- A monolith with clean modules. One process, one PostgreSQL database.
users,catalogandordersare separate files, each created by one function that is its public API. They share the infrastructure (database connection, cache, event bus, queue) and nothing else. Orders never reads the catalog's code; checkout reacts to nothing but its own data, and everything that should happen after a sale listens for anorder.placedevent. - A modular monolith. Each module moves into
src/modules/<name>/of a project generated withzudojs create --architecture modular-monolith, gets its own PostgreSQL schema (catalog.products,orders.orders), and is registered with the runtime. Code stays almost the same; the edges get enforced. - Services, only where needed. When there is a real reason, extract modules into services:
| Service | Owns | Talks to others with |
|---|---|---|
| user | accounts, sessions, roles | HTTP: other services check a session with it (or verify a signed token themselves) |
| product | products, categories, stock | HTTP to reserve and release stock, with an idempotency key; publishes product.changed |
| order | carts, orders | Runs the checkout saga: reserve stock → charge payment → confirm, with compensations; publishes order.placed through an outbox |
| payment | charges, refunds | HTTP with idempotency keys from order; publishes payment.succeeded / payment.failed |
Notice what changes in stage 3: the single database transaction in checkout, the heart of today's code, becomes a saga across services. That is the price of microservices, and why you start with stage 1.
Set up the project
The core uses eleven ZudoJS packages, plus PGlite, the real PostgreSQL that runs inside Node.js and needs no server, which you used in the database lesson. When you move to a real server, you swap PGlite for a pg pool as in the deployment lesson; the SQL stays the same.
mkdir shopflow && cd shopflow npm init -y npm pkg set type=module npm install @zudojs/auth @zudojs/cache @zudojs/crypto @zudojs/errors @zudojs/events @zudojs/http @zudojs/observability @zudojs/permissions @zudojs/queue @zudojs/schema @zudojs/security @electric-sql/pglite added 20 packages, and audited 21 packages in 15s 1 package is looking for funding run `npm fund` for details found 0 vulnerabilities npm install -D typescript tsx @types/node vitest added 44 packages, and audited 65 packages in 17s …
Use the tsconfig.json from the TypeScript setup lesson (strict, NodeNext modules). Then create the files below, in order, in the shopflow folder.
The database
Six tables. Three details carry most of the safety:
- Money is stored as whole cents (
INT), never as a floating-point number, so 0.1 + 0.2 never costs anyone a cent. CHECK (stock >= 0)makes the database itself refuse negative stock. If a bug ever gets past the code, the database stops it.- Sessions store only a hash of the session token. If the database leaks, the tokens in it cannot be used to log in.
import { PGlite } from "@electric-sql/pglite";
export async function createDatabase(): Promise<PGlite> {
const db = new PGlite();
await db.exec(`
CREATE TABLE users (id SERIAL PRIMARY KEY, email TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'customer' CHECK (role IN ('customer', 'admin')));
CREATE TABLE sessions (token_hash TEXT PRIMARY KEY, user_id INT NOT NULL REFERENCES users (id),
expires_at TIMESTAMPTZ NOT NULL);
CREATE TABLE products (id SERIAL PRIMARY KEY, sku TEXT UNIQUE NOT NULL, name TEXT NOT NULL, category TEXT NOT NULL,
price_cents INT NOT NULL CHECK (price_cents > 0), stock INT NOT NULL CHECK (stock >= 0));
CREATE TABLE orders (id SERIAL PRIMARY KEY, user_id INT NOT NULL REFERENCES users (id), total_cents INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now());
CREATE TABLE order_lines (order_id INT NOT NULL REFERENCES orders (id), product_id INT NOT NULL REFERENCES products (id),
quantity INT NOT NULL CHECK (quantity > 0), price_cents INT NOT NULL);
CREATE TABLE audit_log (id SERIAL PRIMARY KEY, actor_id INT, action TEXT NOT NULL, detail JSONB NOT NULL,
at TIMESTAMPTZ NOT NULL DEFAULT now());
`);
return db;
}
Users and sessions
The users module registers, logs in and recognises a logged-in user. It uses @zudojs/auth to hash passwords with scrypt, as in the authentication lesson, and @zudojs/crypto for a random session token. Read the security decisions in the code:
- The input is checked with a schema. The e-mail is trimmed and lower-cased, so
Ada@Example.comandada@example.comare one account. Passwords must be 12 to 128 characters. registernever reads a role from the request. Every new account is acustomer. If the body could setrole, anyone could make themselves an admin.logingives the same answer for "no such e-mail" and "wrong password", and checks a decoy hash when the e-mail is unknown, so both cases take the same time. An attacker cannot find out which e-mails have accounts.- The session token goes to the client once. The database keeps only its hash and an expiry date.
import type { PGlite } from "@electric-sql/pglite";
import { hashPassword, verifyPassword } from "@zudojs/auth";
import { generateSessionToken, hashToken } from "@zudojs/crypto";
import { AuthenticationError, ConflictError } from "@zudojs/errors";
import { schema } from "@zudojs/schema";
export interface User {
readonly id: number;
readonly email: string;
readonly role: "customer" | "admin";
}
const Credentials = schema.object({
email: schema.string().trim().toLowerCase().email(),
password: schema.string().min(12).max(128),
});
export async function createUsers(db: PGlite) {
const decoyHash = await hashPassword(crypto.randomUUID());
return {
async register(input: unknown): Promise<User> {
const { email, password } = Credentials.parse(input);
const passwordHash = await hashPassword(password);
const { rows } = await db.query<User>(
"INSERT INTO users (email, password_hash) VALUES ($1, $2) ON CONFLICT (email) DO NOTHING RETURNING id, email, role",
[email, passwordHash],
);
if (!rows[0]) throw new ConflictError("That e-mail is already registered");
return rows[0];
},
async login(input: unknown): Promise<string> {
const { email, password } = Credentials.parse(input);
const { rows } = await db.query<{ id: number; password_hash: string }>("SELECT id, password_hash FROM users WHERE email = $1", [email]);
const valid = await verifyPassword(password, rows[0]?.password_hash ?? decoyHash);
if (!rows[0] || !valid) throw new AuthenticationError("Wrong e-mail or password");
const token = await generateSessionToken();
await db.query("INSERT INTO sessions (token_hash, user_id, expires_at) VALUES ($1, $2, now() + interval '7 days')", [
await hashToken(token),
rows[0].id,
]);
return token;
},
async authenticate(token: string | undefined): Promise<User> {
if (!token) throw new AuthenticationError("Log in first");
const { rows } = await db.query<User>(
"SELECT u.id, u.email, u.role FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = $1 AND s.expires_at > now()",
[await hashToken(token)],
);
if (!rows[0]) throw new AuthenticationError("Session expired, log in again");
return rows[0];
},
};
}
Products and the cached catalog
The catalog validates new products and serves the product list from @zudojs/cache. Every change clears the products tag, as in Production engineering, so the cache is never older than the last write. Note that this module does not check permissions: it does not know who is calling. That happens at the edge, in the HTTP layer, where the user is known.
import type { PGlite } from "@electric-sql/pglite";
import type { CacheService } from "@zudojs/cache";
import { schema } from "@zudojs/schema";
export interface Product {
readonly sku: string;
readonly name: string;
readonly category: string;
readonly priceCents: number;
readonly stock: number;
}
const NewProduct = schema.object({
sku: schema.string().regex(/^[a-z0-9-]{2,40}$/),
name: schema.string().trim().min(2).max(100),
category: schema.enum(["kitchen", "clothing", "books"]),
priceCents: schema.number().int().min(1).max(1_000_000),
stock: schema.number().int().min(0).max(100_000),
});
const SELECT = `SELECT sku, name, category, price_cents AS "priceCents", stock FROM products`;
export function createCatalog(db: PGlite, cache: CacheService) {
return {
async create(input: unknown): Promise<Product> {
const p = NewProduct.parse(input);
const { rows } = await db.query<Product>(
`INSERT INTO products (sku, name, category, price_cents, stock) VALUES ($1, $2, $3, $4, $5)
RETURNING sku, name, category, price_cents AS "priceCents", stock`,
[p.sku, p.name, p.category, p.priceCents, p.stock],
);
await cache.invalidateByTag(["products"]);
return rows[0]!;
},
async list(): Promise<Product[]> {
const { value } = await cache.getOrSet("products.all", async () => (await db.query<Product>(`${SELECT} ORDER BY sku`)).rows, {
tags: ["products"],
});
return value;
},
};
}
The checkout
This is the core of ShopFlow. For each line of the order, one SQL statement both checks and takes the stock:
UPDATE products SET stock = stock - $1 WHERE sku = $2 AND stock >= $1 RETURNING id, price_cents
If there is not enough stock, the WHERE matches no row, nothing changes, and RETURNING returns nothing. Because the check and the change are one statement, two customers can never both see "1 left" and both buy it: PostgreSQL locks the row for the first UPDATE, and the second one sees the new stock. Reading the stock with a SELECT first and then updating it would have exactly that race.
All lines, the order, its lines and the audit event run in one transaction, from the transactions lesson. If the second line is out of stock, the ConflictError rolls back the first line's stock change too. The price comes from the database, never from the request, so a client cannot choose its own price.
import type { PGlite } from "@electric-sql/pglite";
import { ConflictError } from "@zudojs/errors";
import type { EventBus } from "@zudojs/events";
import { schema } from "@zudojs/schema";
const Checkout = schema.object({
items: schema
.array(schema.object({ sku: schema.string().max(40), quantity: schema.number().int().min(1).max(20) }))
.min(1)
.max(20),
});
export interface OrderPlaced {
readonly orderId: number;
readonly userId: number;
readonly totalCents: number;
}
export function createOrders(db: PGlite, bus: EventBus) {
return {
async checkout(userId: number, input: unknown): Promise<OrderPlaced> {
const { items } = Checkout.parse(input);
const placed = await db.transaction(async (tx) => {
let totalCents = 0;
const lines: Array<{ productId: number; quantity: number; priceCents: number }> = [];
for (const item of items) {
const { rows } = await tx.query<{ id: number; price_cents: number }>(
"UPDATE products SET stock = stock - $1 WHERE sku = $2 AND stock >= $1 RETURNING id, price_cents",
[item.quantity, item.sku],
);
if (!rows[0]) throw new ConflictError(`Not enough stock for ${item.sku}`);
totalCents += rows[0].price_cents * item.quantity;
lines.push({ productId: rows[0].id, quantity: item.quantity, priceCents: rows[0].price_cents });
}
const { rows } = await tx.query<{ id: number }>("INSERT INTO orders (user_id, total_cents) VALUES ($1, $2) RETURNING id", [userId, totalCents]);
const orderId = rows[0]!.id;
for (const line of lines) {
await tx.query("INSERT INTO order_lines (order_id, product_id, quantity, price_cents) VALUES ($1, $2, $3, $4)", [
orderId, line.productId, line.quantity, line.priceCents,
]);
}
await tx.query("INSERT INTO audit_log (actor_id, action, detail) VALUES ($1, 'order.placed', $2)", [userId, { orderId, totalCents }]);
return { orderId, userId, totalCents };
});
await bus.publishEvent({ type: "order.placed", payload: placed });
return placed;
},
};
}
The order.placed event is published after the transaction has committed, so no listener ever reacts to an order that was rolled back. The other risk, a crash between the commit and the publish, is what the outbox from the microservices lesson fixes. It is one of the milestones.
Wiring the shop
app.ts is the composition root. It creates the shared infrastructure once, and the three modules on top of it. It also decides what happens after a sale: count it in a metric, clear the product cache (the stock changed), and queue the receipt e-mail with retries. Permissions come from @zudojs/permissions: customers may read products and create orders; admins may do everything.
import { createCacheService, createMemoryCacheAdapter } from "@zudojs/cache";
import { AuthorizationError } from "@zudojs/errors";
import { createEventBus, type Event } from "@zudojs/events";
import { createObservability } from "@zudojs/observability";
import { createPermissionActor, createPermissionEngine } from "@zudojs/permissions";
import { createInMemoryQueue, createQueueName } from "@zudojs/queue";
import { createCatalog } from "./catalog.js";
import { createDatabase } from "./db.js";
import { createOrders, type OrderPlaced } from "./orders.js";
import { createUsers, type User } from "./users.js";
export async function createShopFlow() {
const db = await createDatabase();
const cache = createCacheService({ adapter: createMemoryCacheAdapter({ maxEntries: 10_000 }), config: { defaultTtl: 60_000 } });
const bus = createEventBus();
const receipts = createInMemoryQueue<OrderPlaced>(createQueueName("receipts"));
const obs = createObservability({ serviceName: "shopflow", useConsoleExporters: false });
const permissions = createPermissionEngine({
roles: [
{ name: "customer", permissions: ["product:read", "order:create"] },
{ name: "admin", permissions: ["*:*"] },
],
});
// Everything that reacts to a sale. Checkout itself knows none of it.
bus.on<Event<OrderPlaced>>("order.placed", async (event) => {
obs.metrics.counter("orders.placed").increment();
await cache.invalidateByTag(["products"]);
await receipts.add("send-receipt", event.payload, { attempts: 5, backoff: { type: "exponential", delay: 1000 } });
});
async function authorize(user: User, permission: string): Promise<void> {
const actor = createPermissionActor(String(user.id), { roles: [user.role] });
if (!(await permissions.can(actor, permission))) throw new AuthorizationError(`Missing permission ${permission}`);
}
return {
db,
receipts,
obs,
authorize,
users: await createUsers(db),
catalog: createCatalog(db, cache),
orders: createOrders(db, bus),
async close() {
await receipts.close();
await obs.shutdown();
await db.close();
},
};
}
export type ShopFlow = Awaited<ReturnType<typeof createShopFlow>>;
The HTTP API
The HTTP layer is thin: it reads the body, finds the user from the Authorization: Bearer … header, checks the permission, and calls a module. Errors from @zudojs/errors carry their own status code (400, 401, 403, 409), and @zudojs/http turns them into JSON answers by itself. Any other error becomes a plain 500 that reveals nothing. Login has its own rate limit, as recommended in the authentication lesson.
import { RateLimitError, ValidationError } from "@zudojs/errors";
import {
createHttpServer,
createNodeHttpAdapter,
createResponseContext,
createRouter,
type HttpRequestContext,
type HttpRouterContext,
} from "@zudojs/http";
import { createRateLimiter } from "@zudojs/security";
import type { ShopFlow } from "./app.js";
function body(ctx: HttpRouterContext): unknown {
const text = new TextDecoder().decode(ctx.request.body as Uint8Array);
try {
return text ? JSON.parse(text) : {};
} catch {
throw new ValidationError("The body must be JSON");
}
}
function bearer(ctx: HttpRouterContext): string | undefined {
const header = ctx.request.getHeader("authorization");
return header?.startsWith("Bearer ") ? header.slice(7) : undefined;
}
export function createShopServer(shop: ShopFlow) {
const router = createRouter();
const logins = createRateLimiter({ windowMs: 60_000, max: 5 });
router.post("/register", async (ctx) => {
const user = await shop.users.register(body(ctx));
return createResponseContext({ status: 201 }).json({ id: user.id, email: user.email });
});
router.post("/login", async (ctx) => {
if (!logins.check({ ip: ctx.request.remoteAddress ?? "unknown" }).allowed) throw new RateLimitError("Too many login attempts");
return createResponseContext().json({ token: await shop.users.login(body(ctx)) });
});
router.get("/products", async () => createResponseContext().json(await shop.catalog.list()));
router.post("/products", async (ctx) => {
await shop.authorize(await shop.users.authenticate(bearer(ctx)), "product:create");
return createResponseContext({ status: 201 }).json(await shop.catalog.create(body(ctx)));
});
router.post("/checkout", async (ctx) => {
const user = await shop.users.authenticate(bearer(ctx));
await shop.authorize(user, "order:create");
return createResponseContext({ status: 201 }).json(await shop.orders.checkout(user.id, body(ctx)));
});
const server = createHttpServer({
adapter: createNodeHttpAdapter({ host: "127.0.0.1", port: 0 }),
handler: async (request: HttpRequestContext) => (await router.dispatch(request)).response,
});
server.on("onStopped", () => logins.destroy());
return server;
}
Run it
main.ts seeds an admin, starts the server on a free port, and plays a short story against the real HTTP API. The passwords are random for each run; nothing secret is written in the code. The first admin is promoted by a direct database update, the way a one-time setup script on the server would do it, never through the API.
import { createShopFlow } from "./app.js";
import type { Product } from "./catalog.js";
import { createShopServer } from "./http.js";
const shop = await createShopFlow();
const receipts: number[] = [];
shop.receipts.process("send-receipt", async (job) => {
receipts.push(job.data.orderId);
});
// Seed: the first admin is made by a script on the server, never through the public API.
const adminLogin = { email: "admin@shopflow.test", password: crypto.randomUUID() };
const admin = await shop.users.register(adminLogin);
await shop.db.query("UPDATE users SET role = 'admin' WHERE id = $1", [admin.id]);
const server = createShopServer(shop);
await server.start();
async function call<T = { token: string }>(method: string, path: string, data?: unknown, token?: string): Promise<T> {
const response = await fetch(`http://127.0.0.1:${server.address?.port}${path}`, {
method,
headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) },
body: data === undefined ? undefined : JSON.stringify(data),
});
const result: unknown = await response.json();
const error = typeof result === "object" && result !== null && "error" in result ? ` ${String(result.error)}` : "";
console.log(`${method} ${path} -> ${response.status}${error}`);
return result as T;
}
const adminToken = (await call("POST", "/login", adminLogin)).token;
await call("POST", "/products", { sku: "mug", name: "ShopFlow mug", category: "kitchen", priceCents: 1200, stock: 2 }, adminToken);
await call("POST", "/products", { sku: "tee", name: "ShopFlow T-shirt", category: "clothing", priceCents: 2000, stock: 5 }, adminToken);
const adaLogin = { email: "Ada@Example.com", password: crypto.randomUUID() };
await call("POST", "/register", { ...adaLogin, role: "admin" });
const ada = (await call("POST", "/login", adaLogin)).token;
await call("POST", "/products", { sku: "free", name: "Free stuff", category: "books", priceCents: 1, stock: 999 }, ada);
console.log(await call("POST", "/checkout", { items: [{ sku: "mug", quantity: 2 }] }, ada));
await call("POST", "/checkout", { items: [{ sku: "tee", quantity: 1 }, { sku: "mug", quantity: 1 }] }, ada);
await call("POST", "/checkout", { items: [{ sku: "tee", quantity: -3 }] }, ada);
await call("POST", "/checkout", { items: [{ sku: "tee", quantity: 1 }] });
const products = await call<Product[]>("GET", "/products");
console.log(products.map((product) => `${product.sku}: ${product.stock} left`).join(", "));
while (receipts.length === 0) await new Promise((resolve) => setTimeout(resolve, 20));
const orders = await shop.db.query("SELECT count(*)::int AS orders FROM orders");
const audit = await shop.db.query<{ action: string; detail: unknown }>("SELECT action, detail FROM audit_log");
console.log(orders.rows[0], "receipts sent:", receipts, "audit:", audit.rows.map((row) => `${row.action} ${JSON.stringify(row.detail)}`));
await server.stop();
await shop.close();
npx tsx main.tsPOST /login -> 200
POST /products -> 201
POST /products -> 201
POST /register -> 201
POST /login -> 200
POST /products -> 403 Missing permission product:create
POST /checkout -> 201
{ orderId: 1, userId: 2, totalCents: 2400 }
POST /checkout -> 409 Not enough stock for mug
POST /checkout -> 400 Validation failed
POST /checkout -> 401 Log in first
GET /products -> 200
mug: 0 left, tee: 5 left
{ orders: 1 } receipts sent: [ 1 ] audit: [ 'order.placed {"orderId":1,"totalCents":2400}' ]Run it with npx tsx main.ts, and read the story line by line:
- The admin created two products. Ada registered with
"role": "admin"in her body, and it was ignored: her attempt to create a product got 403. - Ada bought both mugs: 201, 2 × 12.00 = 24.00, taken from the database's price.
- Then she tried a T-shirt and a mug. The T-shirt line succeeded inside the transaction, the mug line found no stock, and the whole order was rolled back: 409, and the list at the end still shows 5 T-shirts.
- A negative quantity was stopped by the schema (400), and a checkout without a session by the authentication (401).
- Exactly one order exists, one receipt went through the queue, and one audit event was written in the same transaction as the order.
Tests
A story on the screen is not a test. These Vitest tests, from the testing lesson, pin down the rules that matter most. The second one fires four checkouts at the same time for a product with two left, and requires that exactly two succeed:
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createShopFlow, type ShopFlow } from "./app.js";
let shop: ShopFlow;
let userId: number;
beforeAll(async () => {
shop = await createShopFlow();
await shop.catalog.create({ sku: "mug", name: "Mug", category: "kitchen", priceCents: 1200, stock: 3 });
await shop.catalog.create({ sku: "tee", name: "T-shirt", category: "clothing", priceCents: 2000, stock: 5 });
userId = (await shop.users.register({ email: "test@example.com", password: crypto.randomUUID() })).id;
});
afterAll(() => shop.close());
const stockOf = async (sku: string) => (await shop.catalog.list()).find((product) => product.sku === sku)?.stock;
describe("checkout", () => {
it("takes the stock and charges the right total", async () => {
const order = await shop.orders.checkout(userId, { items: [{ sku: "mug", quantity: 1 }] });
expect(order.totalCents).toBe(1200);
expect(await stockOf("mug")).toBe(2);
});
it("never oversells, even when customers check out at the same time", async () => {
const buy = () => shop.orders.checkout(userId, { items: [{ sku: "mug", quantity: 1 }] });
const results = await Promise.allSettled([buy(), buy(), buy(), buy()]);
expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(2);
expect(await stockOf("mug")).toBe(0);
});
it("rolls back every line when one line fails", async () => {
const attempt = shop.orders.checkout(userId, { items: [{ sku: "tee", quantity: 2 }, { sku: "mug", quantity: 1 }] });
await expect(attempt).rejects.toThrow("Not enough stock for mug");
expect(await stockOf("tee")).toBe(5);
});
it("rejects invalid quantities before touching the database", async () => {
await expect(shop.orders.checkout(userId, { items: [{ sku: "tee", quantity: 0 }] })).rejects.toThrow("Validation failed");
});
});
npx vitest run --reporter=verbose RUN v5.0.1 ~/shopflow ✓ checkout.test.ts > checkout > takes the stock and charges the right total 18ms ✓ checkout.test.ts > checkout > never oversells, even when customers check out at the same time 32ms ✓ checkout.test.ts > checkout > rolls back every line when one line fails 5ms ✓ checkout.test.ts > checkout > rejects invalid quantities before touching the database 1ms Test Files 1 passed (1) Tests 4 passed (4) Start at 15:18:06 Duration 7.32s (tests 84%, import 13%, transform 2%)
Each test file creates its own in-memory PostgreSQL, so tests never share data and need no database server. Break the checkout on purpose, for example by removing AND stock >= $1, and watch the second test fail: that is how you know the test protects something.
Milestones
The core covers the hardest rule of the brief. Finish ShopFlow in these milestones. Each one is done when every acceptance criterion holds and is covered by a test. Keep the rules you have seen: validate every input with a schema, parameterized SQL only, permission checks at the edge, one transaction per unit of work, events after commit.
Milestone 1: complete accounts
POST /logoutdeletes the session; the old token then gets 401.- Expired sessions are refused, and a scheduled job with
@zudojs/schedulerdeletes them every night. - Login is limited per IP and per e-mail: the sixth wrong password within a minute gets 429.
- Session tokens travel in an
HttpOnly; Secure; SameSite=Laxcookie for browser clients, with CSRF protection from@zudojs/securityon state-changing routes. - Optional: "Sign in with GitHub" with
@zudojs/auth-oauth, from the OAuth lesson, linked to an existing account only by a verified e-mail.
Milestone 2: products and categories
- A
categoriestable replaces the fixed list;GET /categories/:slug/productslists one category, paginated withLIMIT. PATCH /products/:sku(admin) changes name, price or category; the schema rejects unknown fields and a price of 0.POST /products/:sku/stock(admin) adds or removes stock with a reason; every change writes an audit event in the same transaction.- Past orders keep the price they were sold at, even after a price change.
Milestone 3: cart and orders
- A cart lives in the database (
carts,cart_items), so it survives a restart and works on two devices. GET /ordersreturns only the caller's own orders, andGET /orders/:idanswers 404, not 403, for someone else's order, so ids cannot be probed. Admins see every order.POST /orders/:id/cancelputs the stock back and marks the order cancelled in one transaction, and only the owner or an admin may do it.- A checkout request with an
Idempotency-Keyheader that is sent twice creates one order.
Milestone 4: administration
- A
supportrole may read every order and cancel one, but may not touch products or roles. - Only an admin may change a user's role, never their own, and the last admin cannot be demoted.
GET /admin/auditlists audit events with filters, and every admin action above appears in it.
Milestone 5: infrastructure
- Configuration is read from the environment and checked at startup; a missing secret stops the app with a clear message.
- The receipt queue retries with backoff and moves a job that fails five times to the dead-letter list, which an admin endpoint can show.
order.placedis written to an outbox table in the checkout transaction and published by a relay, so no order is ever missed after a crash./openapi.jsondescribes every route, generated from the routes with@zudojs/openapi, as in the OpenAPI lesson.- JSON logs carry a request id; metrics count requests, errors and checkout latency; each checkout is one trace.
/healthand/readybehave as in Production engineering, and graceful shutdown finishes in-flight checkouts.- The app runs with Docker Compose on real PostgreSQL and Redis, with Caddy in front, as in the deployment lesson, and a backup has been restored.
Milestone 6: modules, then services
- Move the code into a project from
zudojs create --architecture modular-monolith, one module per area, each with its own PostgreSQL schema. A test fails if a module imports another module's private files. - Extract payments first: it has the clearest boundary and the strictest security needs. Orders calls it over HTTP with a timeout and an idempotency key.
- Checkout becomes a saga: reserve stock, charge, confirm; a declined card releases the stock. A test simulates the payment service being down and checks that no stock stays reserved.
- Correlation ids and
traceparentheaders let you follow one checkout through both services.
Practice
TRY IT YOURSELF
Order history without leaking other people's orders
Add history(userId) to the orders module: the caller's orders, newest first, each with its total. Then write the rule for GET /orders/:id in one sentence. Where must the check happen: in the SQL, or after loading the order?
Show a solution
import type { PGlite } from "@electric-sql/pglite";
import { NotFoundError } from "@zudojs/errors";
export function createOrderHistory(db: PGlite) {
return {
async history(userId: number) {
const { rows } = await db.query<{ id: number; totalCents: number }>(
`SELECT id, total_cents AS "totalCents" FROM orders WHERE user_id = $1 ORDER BY id DESC LIMIT 50`,
[userId],
);
return rows;
},
async one(userId: number, orderId: number) {
const { rows } = await db.query<{ id: number; totalCents: number }>(
`SELECT id, total_cents AS "totalCents" FROM orders WHERE id = $1 AND user_id = $2`,
[orderId, userId],
);
if (!rows[0]) throw new NotFoundError(`No order ${orderId}`);
return rows[0];
},
};
}
The rule: a customer may read an order only if it is theirs. Put the owner in the SQL (WHERE id = $1 AND user_id = $2), so an order that is not yours is never even loaded, and answer 404 so nobody can learn which order ids exist. Loading by id alone and checking afterwards works too, but one forgotten check is a data leak; the SQL version cannot forget.
TRY IT YOURSELF
Why not SELECT, then UPDATE?
A teammate rewrites the checkout line as "SELECT stock, if it is enough then UPDATE products SET stock = $new". The tests still pass. Explain what goes wrong in production, and which test would catch it.
Show a solution
Two checkouts for the last mug run at the same time. Both SELECT and see 1. Both decide it is enough. Both write stock = 0. Two mugs are sold, one exists. This is a race condition: the gap between reading and writing. The single UPDATE … WHERE stock >= $1 has no gap. The "never oversells" test with four parallel checkouts catches it on a real PostgreSQL server with several connections. PGlite runs one transaction at a time, so it can hide this race: run that test against real PostgreSQL in CI too.
Recap
- ShopFlow's core runs: hashed passwords, hashed session tokens, no role from the request, a permission check at every protected route, validated input, and errors that map to the right status by themselves.
- The checkout takes stock with one conditional
UPDATEper line inside one transaction, so it never oversells and never leaves half an order. The database'sCHECKis the last line of defence. - Side effects (metrics, cache, receipt e-mail) hang off an event published after the commit, and the tests pin down the rules that matter.
- Six milestones take it from here to a complete shop, a modular monolith and finally services, each with criteria you can test.
One step is left: a final challenge, where you find and fix the problems in an application someone else wrote.
Test yourself
Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.