Capstone Production
A whole project through the CLI
Build a hall-booking API from zudojs create to a Docker image, generating each layer with the CLI and fixing the five things the generator gets wrong.
BY THE END OF THIS LESSON YOU CAN
- Take a project from zudojs create to a running Docker container using only commands the CLI really has
- Turn generated stubs and in-memory resources into real business rules without breaking the generator's wiring
- Spot and fix the five known problems of generated projects in your own code
- Write the tests generated tests cannot: wiring, races, error logging and configuration
- Say which delivery steps belong to the CLI, which to npm scripts and which to your deployment
The problem: an API by Friday
An events company in Lagos rents out halls for weddings, conferences and birthday parties. Today a receptionist keeps the bookings in a notebook, and twice this year the same hall was promised to two families for the same Saturday. They want an API that their new website can call:
- Staff add halls, each with a capacity (how many guests fit) and a daily rate in naira.
- A customer can ask "is Eko Hall free on 24 December, and what does it cost?"
- A customer books a hall for one date. A hall is never booked twice for the same date, and never for more guests than it holds.
- The website team needs API documentation, and the operations team wants a container image it can run.
In The ZudoJS CLI in depth you learned every command one at a time, and in Anatomy of a ZudoJS project you traced the files zudojs create writes. This lesson puts both to work: one project, from an empty folder to a container, using the CLI for everything it can do and your own code for everything it cannot. On the way you will meet five problems that generated projects ship with, and for each one you will learn how to notice it in your own project, not only how to fix it.
Every shell output below is from a real run of zudojs-cli 2.1.3 on Node.js 24, in a folder that is written as ~/code. Every code output comes from running the code on this page.
The plan
| Step | Command | Who does the work |
|---|---|---|
| Create | zudojs create | The CLI |
| Module, resources, schema, validator | zudojs generate module | resource | dto | validator | The CLI writes the files; you write the rules |
| A service inside a module | none that works (see below) | You |
| Tests | no generate test; resource writes one test file | You write the tests that matter |
| Run | zudojs dev | The CLI runs your dev script |
| API docs | --capabilities openapi at create time | The CLI mounts them; your schemas fill them |
| Build, test | zudojs build, npm test | Your npm scripts (tsc, Vitest) |
| Deploy | zudojs add docker, then Docker | The CLI writes a Dockerfile; there is no deploy command |
Step 1: create the project
The company expects more areas later (invoices, staff rotas), so start as a modular monolith: one process, with each area in its own module folder, as in the modular monolith lesson. Ask for the OpenAPI capability now, so the docs are wired from the start. The spinner lines are shortened here:
zudojs create bookings-api --architecture modular-monolith --package-manager npm --capabilities openapi │ ◇ Project structure created │ ◇ Backend project generated (40 files) Capabilities build on: http added 67 packages, and audited 68 packages in 38s … ◆ Dependencies installed ◇ Project validated ◇ Git repository initialized │ └ Project created successfully. cd bookings-api zudojs doctor Zudojs Doctor - Project Diagnostics ✔ Node.js version: Node.js v24.19.0 (meets minimum v24) ✔ Git: git version 2.53.0 ✔ Package manager (npm): 11.19.0 ✔ Zudojs project: backend (modular-monolith) from .zudojs/manifest.json ✔ Package manager: npm (lock file present) ✔ Dependencies installed: node_modules present ✔ TypeScript configuration: tsconfig.json found in every app ✔ Zudojs dependencies: 16 Zudojs package(s) declared ✔ Features: Every declared feature has its package ✔ Capabilities: The manifest and package.json record the same capabilities All checks passed! git add -A && git commit -qm "zudojs create"
That last line matters more than it looks. create initialised a git repository but made no commit, and every generate from now on edits several files at once. With a commit before each step, git diff shows exactly what the generator did, and git restore . plus git clean -fd undo it. Make it a habit: commit, generate, read the diff.
Step 2: a module and two resources
All the booking work lives in one module, scheduling. Preview it, then generate it:
zudojs generate module scheduling --dry-run Detected architecture: modular-monolith Dry run: 7 files would be generated (nothing written): - src/modules/scheduling/scheduling.module.ts - src/modules/scheduling/index.ts - src/modules/index.ts - src/modules/scheduling/features/scheduling.feature.ts - src/modules/scheduling/features/index.ts - src/modules/scheduling/routes/index.ts - src/routes/index.ts zudojs generate module scheduling Detected architecture: modular-monolith Generated 8 files: - src/modules/scheduling/scheduling.module.ts - src/modules/scheduling/index.ts - src/modules/index.ts - src/modules/scheduling/features/scheduling.feature.ts - src/modules/scheduling/features/index.ts - src/modules/scheduling/routes/index.ts - src/app.ts - src/routes/index.ts git diff --stat src/app.ts | 2 ++ src/modules/index.ts | 1 + src/routes/index.ts | 2 ++ 3 files changed, 5 insertions(+)
The dry run promised seven files and the real run wrote eight: it also added new SchedulingModule() to the runtime's list in src/app.ts. That is the difference the CLI lesson warned about, and the reason you read the diff rather than trusting the preview. The module's own routes/index.ts has an empty pair of // zudojs:routes markers, and the app's src/routes/index.ts now calls it.
Now the two resources, inside the module:
zudojs generate resource halls --module scheduling Detected architecture: modular-monolith Generated 8 files: - src/modules/scheduling/dtos/halls.dto.ts - src/modules/scheduling/repositories/halls.repository.ts - src/modules/scheduling/services/halls.service.ts - src/modules/scheduling/controllers/halls.controller.ts - src/modules/scheduling/routes/halls.routes.ts - tests/halls.test.ts - src/modules/scheduling/routes/index.ts - src/container.ts zudojs generate resource bookings --module scheduling Detected architecture: modular-monolith Generated 8 files: - src/modules/scheduling/dtos/bookings.dto.ts - src/modules/scheduling/repositories/bookings.repository.ts - src/modules/scheduling/services/bookings.service.ts - src/modules/scheduling/controllers/bookings.controller.ts - src/modules/scheduling/routes/bookings.routes.ts - tests/bookings.test.ts - src/modules/scheduling/routes/index.ts - src/container.ts npm run typecheck && npm test … Test Files 3 passed (3) Tests 9 passed (9)
Each resource is a working CRUD endpoint (create, read, update, delete) with a name field, an in-memory repository and a test. That is a skeleton, not a booking system: a "booking" with only a name knows nothing about halls, dates or guests. The rest of the lesson turns the skeleton into the product. The three test files are the two new ones plus the example resource's; you will delete the example resource in Step 6.
Step 3: the service that became a module
Booking needs one piece of logic that belongs to neither resource: "is this hall free on this date, and what does it cost?" That is a service, a class holding a use case, and the CLI has a service schematic. Try it:
zudojs generate service availability --module scheduling Detected architecture: modular-monolith Mapping "service" → "module" for modular-monolith architecture. Generated 8 files: - src/modules/availability/availability.module.ts - src/modules/availability/index.ts - src/modules/index.ts - src/modules/availability/features/availability.feature.ts - src/modules/availability/features/index.ts - src/modules/availability/routes/index.ts - src/app.ts - src/routes/index.ts echo $? 0
This is problem 1. In a modular monolith the CLI treats service as another word for module, ignores --module scheduling, and creates a whole second module called availability, registered with the runtime and the router. The exit code is 0, so a script would never notice. How to spot it: the line Mapping "service" → "module", and file paths under src/modules/availability/ instead of src/modules/scheduling/services/. How to undo it, thanks to the commit:
git restore . && git clean -fd src/modules/availability Removing src/modules/availability/ git status --short
Nothing is left. The other schematics do respect --module; these dry runs all wrote inside src/modules/scheduling/:
Command (with --module scheduling) | Writes |
|---|---|
generate repository payments | dtos/payments.dto.ts, repositories/payments.repository.ts |
generate controller invoices | dto, repository, services/invoices.service.ts, controller |
generate dto quotes | dtos/quotes.dto.ts |
generate validator booking | validators/booking.validator.ts |
generate command confirm-booking | commands/confirm-booking/: command, handler, index |
So there are two honest ways to get a service inside a module: generate a controller (which cascades down to a service) and delete what you do not need, or write the file yourself in src/modules/scheduling/services/. The availability service needs two existing repositories rather than a new one, so you will write it by hand in Step 5.
Step 4: decide the rules, then write the schemas
Before you touch a DTO, decide what a valid booking is. This is where most real bugs are born, not in the code.
REASON IT OUT
What can go wrong with a booking?
A customer sends { hallId, date, customerEmail, guests }. Think through each question before reading on:
- Two families press "Book" for Eko Hall on 24 December at the same moment. What must happen?
- What does "today" mean for a company in Lagos when the server's clock runs in UTC? Can someone book yesterday?
- The date arrives as a string. Is
"2026-02-30"a date? Is"24/12/2026"? - How do you store ₦500,000 so that no rounding ever loses a kobo?
- Who decides the price: the request, or the hall?
Ada@Example.comandada@example.com: one customer or two?- The
hallIdis a well-formed UUID, but no hall has it. Which status code?
Show the reasoning
- Exactly one wins; the other gets 409 Conflict. That rule must hold inside the storage layer, not only in a check before it, because two requests can both pass a check before either writes. You will see this race for real in Step 5.
- The business runs on Lagos time, so "today" is the date in
Africa/Lagos, whatever the server's clock says. A date before today is refused with 400. The service gets its "today" from a function you can replace in tests. - Neither. Accept only
YYYY-MM-DD, and check that it is a real calendar day: 30 February is not. - As whole kobo in an integer (₦1 = 100 kobo), the same reason ShopFlow stores cents. ₦500,000 is
50_000_000. - The hall. The request never carries a price; the service copies the hall's daily rate into the booking, so a later price change does not alter old bookings.
- One. Trim and lower-case the e-mail in the schema.
- 404, from the service, which is the layer that knows halls exist. Bad shape (not a UUID at all) is 400 from the controller's schema.
Money and time, checked first
Two of those answers are easy to get wrong in plain JavaScript, so check them before building on them. Displaying kobo as naira is a job for Intl.NumberFormat, never for string concatenation:
const naira = new Intl.NumberFormat("en-NG", { style: "currency", currency: "NGN" });
const formatKobo = (kobo) => naira.format(kobo / 100);
const dailyRateKobo = 50_000_000;
console.log(formatKobo(dailyRateKobo));
console.log(formatKobo(3 * dailyRateKobo));
console.log(Number.isSafeInteger(dailyRateKobo * 365));
node naira.js and of the browser terminal₦500,000.00 ₦1,500,000.00 true
Division by 100 happens only for display; every sum stays in integer kobo. And "today" depends on where you are standing. At 23:30 UTC on 24 September it is already 25 September in Lagos (UTC+1):
const lagosDate = (instant) => instant.toLocaleDateString("en-CA", { timeZone: "Africa/Lagos" });
const lateEvening = new Date("2026-09-24T23:30:00Z");
console.log("UTC date: ", lateEvening.toISOString().slice(0, 10));
console.log("Lagos date:", lagosDate(lateEvening));
console.log("24 Dec is not in the past:", "2026-12-24" >= lagosDate(lateEvening));
node lagos-today.js and of the browser terminalUTC date: 2026-09-24 Lagos date: 2026-09-25 24 Dec is not in the past: true
The en-CA locale writes dates as YYYY-MM-DD, and dates in that form compare correctly as plain strings, which is why the last line needs no date parsing.
The validator stub
For "is this a real calendar date" the CLI has a schematic:
zudojs generate validator booking-date --module scheduling Detected architecture: modular-monolith Generated 1 file: - src/modules/scheduling/validators/booking-date.validator.ts cat src/modules/scheduling/validators/booking-date.validator.ts /** * booking-date validator. */ export function validateBookingDate(input: unknown): boolean { return true; }
This is problem 2: the stub compiles, has a convincing name, and accepts everything. How to spot stubs in your project: search for them before every release, for example grep -rn "return true;" src/**/validators, and keep a test that feeds each validator bad input. Here is the real one:
/**
* booking-date validator.
*/
const PATTERN = /^\d{4}-\d{2}-\d{2}$/;
/** Whether `input` is a real calendar date written as YYYY-MM-DD. */
export function validateBookingDate(input: unknown): boolean {
if (typeof input !== "string" || !PATTERN.test(input)) return false;
const date = new Date(`${input}T00:00:00Z`);
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === input;
}
import { validateBookingDate } from "./src/modules/scheduling/validators/booking-date.validator.js";
const stub = (_input: unknown): boolean => true;
for (const input of ["2026-12-24", "2026-02-30", "2028-02-29", "24/12/2026", "2026-12-24T10:00", 20261224]) {
console.log(String(input).padEnd(17), "stub:", stub(input), " real:", validateBookingDate(input));
}
npx tsx validator-check.ts and of the browser terminal2026-12-24 stub: true real: true 2026-02-30 stub: true real: false 2028-02-29 stub: true real: true 24/12/2026 stub: true real: false 2026-12-24T10:00 stub: true real: false 20261224 stub: true real: false
The trick is the round trip: JavaScript quietly turns 30 February into 2 March, so a date is real only if printing it back gives the same text. 2028 is a leap year, so 29 February passes.
The schemas
The generated DTO files hold the schemas: one @zudojs/schema definition gives both the runtime check and the TypeScript type, as in the validation lesson. Replace the name fields with the real ones:
import { schema, type Infer } from "@zudojs/schema";
/** A hall as the API returns it. Money is in kobo (₦1 = 100 kobo). */
export const HallSchema = schema.object({
id: schema.string().uuid(),
name: schema.string(),
capacity: schema.number().int(),
dailyRateKobo: schema.number().int(),
createdAt: schema.string(),
updatedAt: schema.string(),
});
/** Body of `POST /api/v1/halls`. Unknown fields are dropped. */
export const CreateHallSchema = schema.object({
name: schema.string().trim().min(1).max(100),
capacity: schema.number().int().min(1).max(5000),
dailyRateKobo: schema.number().int().min(100).max(1_000_000_000),
});
/** Body of `PATCH /api/v1/halls/:id`. Every field is optional. */
export const UpdateHallSchema = CreateHallSchema.partial();
/** Path parameters of the `/api/v1/halls/:id` routes. */
export const HallParamsSchema = schema.object({
id: schema.string().uuid(),
});
export type Hall = Infer<typeof HallSchema>;
export type CreateHallInput = Infer<typeof CreateHallSchema>;
export type UpdateHallInput = Infer<typeof UpdateHallSchema>;
import { schema, type Infer } from "@zudojs/schema";
import { validateBookingDate } from "../validators/booking-date.validator.js";
/** A booking as the API returns it. */
export const BookingSchema = schema.object({
id: schema.string().uuid(),
hallId: schema.string().uuid(),
date: schema.string(),
customerEmail: schema.string(),
guests: schema.number().int(),
amountKobo: schema.number().int(),
createdAt: schema.string(),
updatedAt: schema.string(),
});
/** Body of `POST /api/v1/bookings`. Unknown fields are dropped. */
export const CreateBookingSchema = schema.object({
hallId: schema.string().uuid(),
date: schema.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(validateBookingDate, "Expected a real date written as YYYY-MM-DD"),
customerEmail: schema.string().trim().toLowerCase().email(),
guests: schema.number().int().min(1),
});
/** Body of `PATCH /api/v1/bookings/:id`. Every field is optional. */
export const UpdateBookingSchema = schema.object({
guests: schema.number().int().min(1).optional(),
});
/** Path parameters of the `/api/v1/bookings/:id` routes. */
export const BookingParamsSchema = schema.object({
id: schema.string().uuid(),
});
export type Booking = Infer<typeof BookingSchema>;
export type CreateBookingInput = Infer<typeof CreateBookingSchema>;
export type UpdateBookingInput = Infer<typeof UpdateBookingSchema>;
/** What the repository stores: the input plus the price the service worked out. */
export type NewBooking = CreateBookingInput & { readonly amountKobo: number };
Notice what is not in CreateBookingSchema: no price, no status. The date has both a regex and the validator: the regex is visible in the API documentation as a pattern (you will see it in Step 7), while the refine is a check the documentation cannot describe. The halls repository needs two new lines in create and two in update, so it copies capacity and dailyRateKobo; here is the file after that edit:
import { randomUUID } from "node:crypto";
import { ConflictError } from "@zudojs/errors";
import type { CreateHallInput, Hall, UpdateHallInput } from "../dtos/halls.dto.js";
/** Storage contract for hall records; the service depends on this only. */
export interface HallsRepository {
findAll(): Promise<readonly Hall[]>;
findById(id: string): Promise<Hall | undefined>;
create(input: CreateHallInput): Promise<Hall>;
update(id: string, input: UpdateHallInput): Promise<Hall | undefined>;
delete(id: string): Promise<boolean>;
}
/** Most records the in-memory store keeps, so it cannot exhaust memory. */
const MAX_RECORDS = 10_000;
/**
* Keeps hall records in process memory: for development and tests.
* Data is lost on restart. Swap the implementation in container.ts.
*/
export class InMemoryHallsRepository implements HallsRepository {
private readonly records = new Map<string, Hall>();
public async findAll(): Promise<readonly Hall[]> {
return [...this.records.values()];
}
public async findById(id: string): Promise<Hall | undefined> {
return this.records.get(id);
}
public async create(input: CreateHallInput): Promise<Hall> {
if (this.records.size >= MAX_RECORDS) {
throw new ConflictError("The in-memory hall store is full.");
}
const now = new Date().toISOString();
const record: Hall = {
id: randomUUID(),
name: input.name,
capacity: input.capacity,
dailyRateKobo: input.dailyRateKobo,
createdAt: now,
updatedAt: now,
};
this.records.set(record.id, record);
return record;
}
public async update(
id: string,
input: UpdateHallInput,
): Promise<Hall | undefined> {
const existing = this.records.get(id);
if (existing === undefined) return undefined;
const updated: Hall = {
...existing,
name: input.name ?? existing.name,
capacity: input.capacity ?? existing.capacity,
dailyRateKobo: input.dailyRateKobo ?? existing.dailyRateKobo,
updatedAt: new Date().toISOString(),
};
this.records.set(id, updated);
return updated;
}
public async delete(id: string): Promise<boolean> {
return this.records.delete(id);
}
}
Step 5: the repository and the double booking
The bookings repository needs a lookup by hall and date, and its create must refuse a hall that is already taken. The first version most people write looks like this: ask, then insert.
import { randomUUID } from "node:crypto";
import { ConflictError } from "@zudojs/errors";
import type { Booking, NewBooking, UpdateBookingInput } from "../dtos/bookings.dto.js";
/** Storage contract for booking records; the service depends on this only. */
export interface BookingsRepository {
findAll(): Promise<readonly Booking[]>;
findById(id: string): Promise<Booking | undefined>;
findByHallAndDate(hallId: string, date: string): Promise<Booking | undefined>;
/** Throws ConflictError when the hall is already booked on that date. */
create(input: NewBooking): Promise<Booking>;
update(id: string, input: UpdateBookingInput): Promise<Booking | undefined>;
delete(id: string): Promise<boolean>;
}
/** Most records the in-memory store keeps, so it cannot exhaust memory. */
const MAX_RECORDS = 10_000;
/**
* Keeps booking records in process memory: for development and tests.
* Data is lost on restart. Swap the implementation in container.ts.
*/
export class InMemoryBookingsRepository implements BookingsRepository {
private readonly records = new Map<string, Booking>();
public async findAll(): Promise<readonly Booking[]> {
return [...this.records.values()];
}
public async findById(id: string): Promise<Booking | undefined> {
return this.records.get(id);
}
public async findByHallAndDate(hallId: string, date: string): Promise<Booking | undefined> {
return this.taken(hallId, date);
}
public async create(input: NewBooking): Promise<Booking> {
if (this.records.size >= MAX_RECORDS) {
throw new ConflictError("The in-memory booking store is full.");
}
// Check and insert with no await in between, so two requests cannot both pass.
if (this.taken(input.hallId, input.date) !== undefined) {
throw new ConflictError(`The hall is already booked on ${input.date}.`);
}
const now = new Date().toISOString();
const record: Booking = { id: randomUUID(), ...input, createdAt: now, updatedAt: now };
this.records.set(record.id, record);
return record;
}
public async update(id: string, input: UpdateBookingInput): Promise<Booking | undefined> {
const existing = this.records.get(id);
if (existing === undefined) return undefined;
const updated: Booking = {
...existing,
guests: input.guests ?? existing.guests,
updatedAt: new Date().toISOString(),
};
this.records.set(id, updated);
return updated;
}
public async delete(id: string): Promise<boolean> {
return this.records.delete(id);
}
private taken(hallId: string, date: string): Booking | undefined {
for (const record of this.records.values()) {
if (record.hallId === hallId && record.date === date) return record;
}
return undefined;
}
}
The file above is the finished version. The comment in create is the whole story: the check uses the private, synchronous taken, not await this.findByHallAndDate(…). That one await makes the difference between one booking and two. Here are both versions, each asked to book the same hall twice at the same moment:
import { ConflictError } from "@zudojs/errors";
import type { Booking, NewBooking } from "./src/modules/scheduling/dtos/bookings.dto.js";
import { InMemoryBookingsRepository } from "./src/modules/scheduling/repositories/bookings.repository.js";
// The first draft: look up with await, then insert.
class AwaitThenInsertRepository {
private readonly records: Booking[] = [];
async findByHallAndDate(hallId: string, date: string): Promise<Booking | undefined> {
return this.records.find((record) => record.hallId === hallId && record.date === date);
}
async create(input: NewBooking): Promise<Booking> {
if ((await this.findByHallAndDate(input.hallId, input.date)) !== undefined) {
throw new ConflictError(`The hall is already booked on ${input.date}.`);
}
const record: Booking = { id: crypto.randomUUID(), ...input, createdAt: "", updatedAt: "" };
this.records.push(record);
return record;
}
}
const ada: NewBooking = {
hallId: "0b6f3c1e-8d4a-4f7e-9a2b-5c6d7e8f9a0b",
date: "2026-12-24",
customerEmail: "ada@example.com",
guests: 150,
amountKobo: 50_000_000,
};
const tunde: NewBooking = { ...ada, customerEmail: "tunde@example.com", guests: 80 };
for (const repository of [new AwaitThenInsertRepository(), new InMemoryBookingsRepository()]) {
const results = await Promise.allSettled([repository.create(ada), repository.create(tunde)]);
console.log(repository.constructor.name.padEnd(27), results.map((result) => result.status).join(", "));
}
npx tsx race.tsAwaitThenInsertRepository fulfilled, fulfilled InMemoryBookingsRepository fulfilled, rejected
Follow the first line step by step. Ada's create calls findByHallAndDate and hits await, which pauses it until the next microtask. Tunde's create now starts, looks, finds nothing (Ada has not inserted yet) and pauses too. Then Ada inserts, then Tunde inserts. Two families, one hall. In the finished version there is no pause between looking and inserting, so JavaScript's single thread runs Ada's check-and-insert to the end before Tunde's begins. This is the race condition from the concurrency lesson, and the in-memory version of what a database UNIQUE (hall_id, date) constraint gives you for free.
THIS ONLY HOLDS IN ONE PROCESS
AMap in memory is correct for one process. Run two copies of the app behind a load balancer and each has its own Map, so both can accept the same date. When you move to PostgreSQL, put the rule in the database: UNIQUE (hall_id, date), and turn the unique-violation error into a ConflictError.Step 6: services and wiring
Now the service the CLI could not generate. It reads from both repositories and answers "free, and at what price". Its today is a parameter with the Lagos clock as the default, so a test can pin it to a fixed day:
import { NotFoundError, ValidationError } from "@zudojs/errors";
import type { Hall } from "../dtos/halls.dto.js";
import type { BookingsRepository } from "../repositories/bookings.repository.js";
import type { HallsRepository } from "../repositories/halls.repository.js";
/** Today's date in Lagos as YYYY-MM-DD. */
export const lagosToday = (): string => new Date().toLocaleDateString("en-CA", { timeZone: "Africa/Lagos" });
/** Whether a hall is free on a date, and what it costs. */
export interface Quote {
readonly hallId: string;
readonly date: string;
readonly available: boolean;
readonly capacity: number;
readonly amountKobo: number;
}
/** Answers "can this hall be booked on this date?" for the bookings module. */
export class AvailabilityService {
public constructor(
private readonly halls: HallsRepository,
private readonly bookings: BookingsRepository,
private readonly today: () => string = lagosToday,
) {}
public async hall(hallId: string): Promise<Hall> {
const hall = await this.halls.findById(hallId);
if (hall === undefined) throw new NotFoundError(`Hall "${hallId}" was not found.`);
return hall;
}
public async quote(hallId: string, date: string): Promise<Quote> {
const hall = await this.hall(hallId);
if (date < this.today()) throw new ValidationError(`${date} is in the past.`);
const booking = await this.bookings.findByHallAndDate(hallId, date);
return { hallId, date, available: booking === undefined, capacity: hall.capacity, amountKobo: hall.dailyRateKobo };
}
}
The generated BookingsService passed input straight to the repository. It now asks for a quote, checks the guest count, and stores the hall's price:
import { NotFoundError, ValidationError } from "@zudojs/errors";
import type { CreateBookingInput, Booking, UpdateBookingInput } from "../dtos/bookings.dto.js";
import type { BookingsRepository } from "../repositories/bookings.repository.js";
import type { AvailabilityService } from "./availability.service.js";
/** Booking use cases. Throws NotFoundError (404), ValidationError (400), ConflictError (409). */
export class BookingsService {
public constructor(
private readonly repository: BookingsRepository,
private readonly availability: AvailabilityService,
) {}
public list(): Promise<readonly Booking[]> {
return this.repository.findAll();
}
public async get(id: string): Promise<Booking> {
const record = await this.repository.findById(id);
if (record === undefined) throw notFound(id);
return record;
}
public async create(input: CreateBookingInput): Promise<Booking> {
const quote = await this.availability.quote(input.hallId, input.date);
checkGuests(input.guests, quote.capacity);
return this.repository.create({ ...input, amountKobo: quote.amountKobo });
}
public async update(id: string, input: UpdateBookingInput): Promise<Booking> {
const existing = await this.get(id);
if (input.guests !== undefined) {
checkGuests(input.guests, (await this.availability.hall(existing.hallId)).capacity);
}
const record = await this.repository.update(id, input);
if (record === undefined) throw notFound(id);
return record;
}
public async remove(id: string): Promise<void> {
if (!(await this.repository.delete(id))) throw notFound(id);
}
}
function checkGuests(guests: number, capacity: number): void {
if (guests > capacity) throw new ValidationError(`The hall holds ${capacity} guests, not ${guests}.`);
}
function notFound(id: string): NotFoundError {
return new NotFoundError(`Booking "${id}" was not found.`);
}
create does not check quote.available: the repository is the one place that can decide that without a race, so the service lets it. Every rule from the reasoning block now has a home. Run them all against the real classes, with "today" pinned to 1 October:
import { InMemoryBookingsRepository } from "./src/modules/scheduling/repositories/bookings.repository.js";
import { InMemoryHallsRepository } from "./src/modules/scheduling/repositories/halls.repository.js";
import { AvailabilityService } from "./src/modules/scheduling/services/availability.service.js";
import { BookingsService } from "./src/modules/scheduling/services/bookings.service.js";
import type { CreateBookingInput } from "./src/modules/scheduling/dtos/bookings.dto.js";
const halls = new InMemoryHallsRepository();
const bookings = new InMemoryBookingsRepository();
const availability = new AvailabilityService(halls, bookings, () => "2026-10-01");
const service = new BookingsService(bookings, availability);
const eko = await halls.create({ name: "Eko Hall", capacity: 200, dailyRateKobo: 50_000_000 });
async function attempt(label: string, input: CreateBookingInput): Promise<void> {
try {
const booking = await service.create(input);
console.log(`${label.padEnd(9)} 201 booked for ${booking.amountKobo} kobo`);
} catch (error) {
const { statusCode, name, message } = error as Error & { statusCode: number };
console.log(`${label.padEnd(9)} ${statusCode} ${name}: ${message}`);
}
}
const ada = { hallId: eko.id, date: "2026-12-24", customerEmail: "ada@example.com", guests: 150 };
await attempt("ada", ada);
await attempt("tunde", { ...ada, customerEmail: "tunde@example.com", guests: 80 });
await attempt("too many", { ...ada, date: "2026-12-26", guests: 350 });
await attempt("past", { ...ada, date: "2026-09-30" });
await attempt("no hall", { ...ada, hallId: "7a1c7a0e-5d7e-4f5b-9c1d-2b3e4f5a6b7c" });
const quote = await availability.quote(eko.id, "2026-12-24");
console.log("24 Dec available:", quote.available, "| 25 Dec available:", (await availability.quote(eko.id, "2026-12-25")).available);
npx tsx booking-rules.tsada 201 booked for 50000000 kobo tunde 409 ConflictError: The hall is already booked on 2026-12-24. too many 400 ValidationError: The hall holds 200 guests, not 350. past 400 ValidationError: 2026-09-30 is in the past. no hall 404 NotFoundError: Hall "7a1c7a0e-5d7e-4f5b-9c1d-2b3e4f5a6b7c" was not found. 24 Dec available: false | 25 Dec available: true
Each rule answers with the status the reasoning block chose, and the error classes from @zudojs/errors carry that status themselves, so the controller and dispatch need no mapping table. The quote at the end sees Ada's booking because the service and the availability check share one repository object. Making them share it in the running app is the next job.
Wiring at the composition root
The generator wired each resource with its own repository: new BookingsService(new InMemoryBookingsRepository()). Now two services must share the same repositories, which the generator cannot know. Build them once in src/container.ts, outside the markers, and delete the example resource while you are there (its five files, its test, and its lines):
export function createDependencies(options: DependencyOptions = {}) {
const health: HealthCheck =
options.health ?? (async () => ({ ready: true, checks: {} }));
// Shared by two services, so built once, outside the markers.
const hallsRepository = new InMemoryHallsRepository();
const bookingsRepository = new InMemoryBookingsRepository();
const availability = new AvailabilityService(hallsRepository, bookingsRepository);
return {
health,
availability,
hallsController: new HallsController(new HallsService(hallsRepository)),
bookingsController: new BookingsController(new BookingsService(bookingsRepository, availability)),
// zudojs:container:start
// zudojs:container:end
};
}
The availability route is yours too. It uses the small HTTP helpers every generated controller uses, from src/utils/http.ts. That file is unchanged from zudojs create (the anatomy lesson explains each function); it is repeated here because the examples below run against it:
import {
badRequest,
createResponseContext,
type HttpMiddleware,
type HttpResponseContext,
type HttpRouterContext,
} from "@zudojs/http";
import type { SchemaIssue } from "@zudojs/schema";
import { generateSecurityHeaders } from "@zudojs/security";
/** A JSON response with `status`. */
export function json(status: number, data: unknown): HttpResponseContext {
return createResponseContext({ status }).json(data);
}
/** A response with no body (for example 204). */
export function empty(status: number): HttpResponseContext {
return createResponseContext({ status });
}
/** 400 listing where the input failed validation, without echoing it back. */
export function validationFailed(issues: readonly SchemaIssue[]): HttpResponseContext {
return json(400, {
error: "Validation failed",
issues: issues.map((issue) => ({
path: issue.path.map(String).join("."),
message: issue.message,
})),
});
}
/**
* The request body parsed as JSON; `undefined` when there is none.
* Malformed JSON is answered with 400.
*/
export function readJsonBody(ctx: HttpRouterContext): unknown {
const body: unknown = ctx.request.body;
if (body === undefined || body === null) return undefined;
const text =
body instanceof Uint8Array
? new TextDecoder().decode(body)
: typeof body === "string"
? body
: undefined;
if (text === undefined) return body;
if (text.trim() === "") return undefined;
try {
return JSON.parse(text) as unknown;
} catch {
throw badRequest("The request body is not valid JSON.");
}
}
/**
* The response for an error that carries an exposed 4xx status
* (NotFoundError, badRequest(), ...). Anything else is left to the server,
* which answers a generic 500 and never leaks the message.
*/
export function errorResponse(error: unknown): HttpResponseContext | undefined {
if (typeof error !== "object" || error === null) return undefined;
const candidate = error as {
readonly statusCode?: unknown;
readonly expose?: unknown;
readonly message?: unknown;
readonly code?: unknown;
};
const status = candidate.statusCode;
if (typeof status !== "number" || status < 400 || status > 499) return undefined;
if (candidate.expose !== true || typeof candidate.message !== "string") return undefined;
return json(status, {
error: candidate.message,
...(typeof candidate.code === "string" ? { code: candidate.code } : {}),
});
}
/**
* Adds the @zudojs/security default headers (CSP, HSTS, nosniff,
* X-Frame-Options DENY, ...) to every response that does not set its own:
* the /docs page, for instance, sends a CSP that allows its assets.
*/
export function securityHeaders(): HttpMiddleware {
const defaults = Object.entries(generateSecurityHeaders());
return async (_context, next) => {
const response = (await next()).clone();
const present = new Set(Object.keys(response.headers).map((name) => name.toLowerCase()));
for (const [name, value] of defaults) {
if (!present.has(name.toLowerCase())) response.setHeader(name, value);
}
return response;
};
}
The route lives next to the generated routes and is registered, again, above the markers in the module's routes/index.ts:
import type { HttpRouter } from "@zudojs/http";
import { schema } from "@zudojs/schema";
import { json, validationFailed } from "../../../utils/http.js";
import { HallParamsSchema } from "../dtos/halls.dto.js";
import type { AvailabilityService } from "../services/availability.service.js";
import { validateBookingDate } from "../validators/booking-date.validator.js";
const AvailabilityQuerySchema = schema.object({
date: schema.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(validateBookingDate, "Expected a real date written as YYYY-MM-DD"),
});
/** GET /api/v1/halls/:id/availability?date=YYYY-MM-DD, written by hand. */
export function registerAvailabilityRoutes(router: HttpRouter, availability: AvailabilityService): void {
router.get(
"/api/v1/halls/:id/availability",
async (ctx) => {
const params = HallParamsSchema.safeParse(ctx.params);
if (!params.success) return validationFailed(params.issues);
const query = AvailabilityQuerySchema.safeParse(ctx.query);
if (!query.success) return validationFailed(query.issues);
return json(200, await availability.quote(params.data.id, query.data.date));
},
{
openapi: {
summary: "Check whether a hall is free on a date",
tags: ["halls"],
params: HallParamsSchema,
query: AvailabilityQuerySchema,
responses: { "200": { description: "Availability and price" }, "400": { description: "Bad id or date" }, "404": { description: "No such hall" } },
},
},
);
}
import type { HttpRouter } from "@zudojs/http";
import type { AppDependencies } from "../../../container.js";
import { registerAvailabilityRoutes } from "./availability.routes.js";
// zudojs:route-imports:start
import { registerHallsRoutes } from "./halls.routes.js";
import { registerBookingsRoutes } from "./bookings.routes.js";
// zudojs:route-imports:end
/** Registers this module's routes; called from the app's routes/index.ts. */
export function registerSchedulingModuleRoutes(router: HttpRouter, deps: AppDependencies): void {
registerAvailabilityRoutes(router, deps.availability);
// zudojs:routes:start
registerHallsRoutes(router, deps.hallsController);
registerBookingsRoutes(router, deps.bookingsController);
// zudojs:routes:end
}
The controller and routes files the generator wrote for bookings need no change at all: they validate with the DTO schemas, which you changed, and call the service, which you changed. That is the layering paying off.
What --force would do now
You moved the bookings wiring out of the markers on purpose. What happens if a teammate later regenerates the resource "to get the latest template"?
zudojs generate resource bookings --module scheduling --force Detected architecture: modular-monolith Generated 7 files: - src/modules/scheduling/dtos/bookings.dto.ts - src/modules/scheduling/repositories/bookings.repository.ts - src/modules/scheduling/services/bookings.service.ts - src/modules/scheduling/controllers/bookings.controller.ts - src/modules/scheduling/routes/bookings.routes.ts - tests/bookings.test.ts - src/container.ts npm run typecheck … src/container.ts(39,88): error TS2554: Expected 1 arguments, but got 2. src/container.ts(42,5): error TS1117: An object literal cannot have multiple properties with the same name. src/modules/scheduling/services/availability.service.ts(36,41): error TS2339: Property 'findByHallAndDate' does not exist on type 'BookingsRepository'. git restore .
The DTO, repository and service went back to the plain name template, and the generator added a second bookingsController between the markers, because its line was no longer there. Here the type checker caught it; a change the types cannot see would have been lost silently. The rule: once you have written real code in a resource, never --force it, and if you must, commit first and read the whole diff.
Step 7: test, run and read the docs
The generated tests break, and the type checker misses it
npm run typecheck > bookings-api@0.1.0 typecheck > tsc --noEmit npm test … FAIL tests/bookings.test.ts > /api/v1/bookings > creates, reads, updates and deletes a booking AssertionError: Expected status 201, got 400. request: POST /api/v1/bookings response: 400 Bad Request body: {"error":"Validation failed","issues":[{"path":"hallId","message":"Required field missing: hallId"},{"path":"date","message":"Required field missing: date"},{"path":"customerEmail","message":"Required field missing: customerEmail"},{"path":"guests","message":"Required field missing: guests"}]} … Test Files 2 failed (2) Tests 2 failed | 4 passed (6)
The failures are expected: the tests still send { name: "Ada" }. The surprise is the line above them. tests/bookings.test.ts calls new BookingsService(new InMemoryBookingsRepository()) with one argument where the class now needs two, and npm run typecheck said nothing. The generated tsconfig.json has "include": ["src/**/*"] and excludes **/*.test.ts, so tests are never type-checked. Vitest strips types without checking them. Add a second config for tests:
{
"extends": "./tsconfig.json",
"compilerOptions": { "rootDir": ".", "noEmit": true },
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"]
}
npm pkg set 'scripts.typecheck:tests=tsc -p tsconfig.test.json' npm run typecheck:tests … tests/bookings.test.ts(13,26): error TS2554: Expected 2 arguments, but got 1.
Then rewrite the two tests for the real fields. The full test suite is in Step 9.
Run it
zudojs dev prints what it detected and runs your dev script; -p sets PORT:
cp .env.example .env zudojs dev -p 4100 Project type: backend Backend architecture: modular-monolith Starting backend: npm run dev > bookings-api@0.1.0 dev > tsx watch src/server.ts 2026-09-25T00:07:09.491Z [INFO] [bookings-api] scheduling module initialized 2026-09-25T00:07:09.494Z [INFO] [bookings-api] All modules initialized. modules=["integrations","scheduling"] durationMs=5 2026-09-25T00:07:09.495Z [INFO] [bookings-api] All modules started. modules=["integrations","scheduling"] durationMs=0 2026-09-25T00:07:09.496Z [INFO] [bookings-api] Runtime is ready. runtimeId=rt_83ceb57ee8774e26905f45ae9fe60057 environment=development Listening on http://0.0.0.0:4100
In a second terminal, play the receptionist's worst day. $HALL holds the id from the first answer:
curl -s -X POST localhost:4100/api/v1/halls -H 'content-type: application/json' -d '{"name":"Eko Hall","capacity":200,"dailyRateKobo":50000000}' {"id":"4609fe32-515a-41b9-b4c9-76efd7bf7b5c","name":"Eko Hall","capacity":200,"dailyRateKobo":50000000,"createdAt":"2026-09-25T00:07:09.664Z","updatedAt":"2026-09-25T00:07:09.664Z"} HALL=4609fe32-515a-41b9-b4c9-76efd7bf7b5c curl -s "localhost:4100/api/v1/halls/$HALL/availability?date=2026-12-24" {"hallId":"4609fe32-515a-41b9-b4c9-76efd7bf7b5c","date":"2026-12-24","available":true,"capacity":200,"amountKobo":50000000} curl -s -w ' %{http_code}\n' -X POST localhost:4100/api/v1/bookings -H 'content-type: application/json' -d "{\"hallId\":\"$HALL\",\"date\":\"2026-12-24\",\"customerEmail\":\"Ada@Example.com\",\"guests\":150}" {"id":"feae5a98-3455-4c95-9979-bf1bb2d82abf","hallId":"4609fe32-515a-41b9-b4c9-76efd7bf7b5c","date":"2026-12-24","customerEmail":"ada@example.com","guests":150,"amountKobo":50000000,"createdAt":"2026-09-25T00:07:09.766Z","updatedAt":"2026-09-25T00:07:09.766Z"} 201 curl -s -w ' %{http_code}\n' -X POST localhost:4100/api/v1/bookings -H 'content-type: application/json' -d "{\"hallId\":\"$HALL\",\"date\":\"2026-12-24\",\"customerEmail\":\"tunde@example.com\",\"guests\":80}" {"error":"The hall is already booked on 2026-12-24.","code":"ERR_CONFLICT"} 409 curl -s -w ' %{http_code}\n' -X POST localhost:4100/api/v1/bookings -H 'content-type: application/json' -d "{\"hallId\":\"$HALL\",\"date\":\"2026-02-30\",\"customerEmail\":\"tunde@example.com\",\"guests\":80}" {"error":"Validation failed","issues":[{"path":"date","message":"Expected a real date written as YYYY-MM-DD"}]} 400 curl -s "localhost:4100/api/v1/halls/$HALL/availability?date=2026-12-24" {"hallId":"4609fe32-515a-41b9-b4c9-76efd7bf7b5c","date":"2026-12-24","available":false,"capacity":200,"amountKobo":50000000}
The second availability answer is available: false: the two services really share one repository. Two things happen in the first terminal as you work. Every time you save a file, tsx watch restarts the server with SIGTERM, so the log shows Received SIGTERM: shutting down. and a full graceful shutdown. And after the restart, curl localhost:4100/api/v1/halls answers []: the in-memory repositories forgot everything. That is fine for development and fatal in production.
The API documentation
Because you asked for --capabilities openapi, src/server.ts contains mountOpenAPI(router, …), which serves /openapi.json and a documentation page at /docs, built from the routes and their schemas as in the OpenAPI lesson. The generated routes, your hand-written route and the enquiries resource you will add later all appear:
curl -s localhost:4100/openapi.json | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const d=JSON.parse(s);console.log(d.openapi, d.info.title, d.info.version);for(const [p,ops] of Object.entries(d.paths))console.log(p.padEnd(34), Object.keys(ops).map(m=>m.toUpperCase()).join(" "))})' 3.1.0 bookings-api 0.1.0 /api/v1/halls/{id}/availability GET /api/v1/halls GET POST /api/v1/halls/{id} GET PATCH DELETE /api/v1/bookings GET POST /api/v1/bookings/{id} GET PATCH DELETE /api/v1/enquiries GET POST /api/v1/enquiries/{id} GET PATCH DELETE curl -s -o /dev/null -w '%{http_code} %{content_type}\n' localhost:4100/docs 200 text/html; charset=utf-8
The document is only as good as your schemas. This runs the real availability route and prints how its query parameter is documented, then calls it with a good and a bad date:
import { createRouter, mountOpenAPI } from "@zudojs/http";
import { createHttpTestClient } from "@zudojs/testing";
import { InMemoryBookingsRepository } from "./src/modules/scheduling/repositories/bookings.repository.js";
import { InMemoryHallsRepository } from "./src/modules/scheduling/repositories/halls.repository.js";
import { registerAvailabilityRoutes } from "./src/modules/scheduling/routes/availability.routes.js";
import { AvailabilityService } from "./src/modules/scheduling/services/availability.service.js";
const halls = new InMemoryHallsRepository();
const availability = new AvailabilityService(halls, new InMemoryBookingsRepository(), () => "2026-10-01");
const eko = await halls.create({ name: "Eko Hall", capacity: 200, dailyRateKobo: 50_000_000 });
const router = createRouter();
registerAvailabilityRoutes(router, availability);
mountOpenAPI(router, { info: { title: "bookings-api", version: "0.1.0" } });
const client = createHttpTestClient(router);
type Operation = { parameters: Array<{ in: string; name: string; schema: unknown }> };
const doc = (await client.get("/openapi.json")).json<{ paths: Record<string, { get: Operation }> }>();
for (const parameter of doc.paths["/api/v1/halls/{id}/availability"]!.get.parameters) {
console.log(parameter.in.padEnd(5), parameter.name.padEnd(4), JSON.stringify(parameter.schema));
}
console.log((await client.get(`/api/v1/halls/${eko.id}/availability?date=2026-12-24`)).text);
const bad = await client.get(`/api/v1/halls/${eko.id}/availability?date=24-12-2026`);
console.log(bad.status, bad.text);
await client.close();
npx tsx openapi-check.tspath id {"type":"string","format":"uuid","maxLength":255}
query date {"type":"string","maxLength":255,"pattern":"^\\d{4}-\\d{2}-\\d{2}$"}
{"hallId":"3e2f2228-f73a-4c1d-8e79-6b575d16b24a","date":"2026-12-24","available":true,"capacity":200,"amountKobo":50000000}
400 {"error":"Validation failed","issues":[{"path":"date","message":"String does not match pattern"}]}The regex became a pattern the website team can read. Before you added the regex, the date was documented as just "type":"string": the validator's refine runs, but no document can describe a JavaScript function. When a rule matters to your callers, express as much of it as you can in a form the schema can publish, and write the rest in the route's summary or description.
Step 8: three more problems the generator ships
You have met problem 1 (service became a module) and problem 2 (the validator stub). Three more hide in every generated project. None of them fails a build or a generated test, so you find them only if you know to look.
Problem 3: missing markers only warn
While writing the availability route, suppose you had "tidied" the module's routes/index.ts and deleted the marker comments. A week later the company wants customers to send enquiries:
zudojs generate resource enquiries --module scheduling Detected architecture: modular-monolith Generated 7 files: - src/modules/scheduling/dtos/enquiries.dto.ts - src/modules/scheduling/repositories/enquiries.repository.ts - src/modules/scheduling/services/enquiries.service.ts - src/modules/scheduling/controllers/enquiries.controller.ts - src/modules/scheduling/routes/enquiries.routes.ts - tests/enquiries.test.ts - src/container.ts Warning: Finish by hand: - src/modules/scheduling/routes/index.ts: add "import { registerEnquiriesRoutes } from "./enquiries.routes.js";" between "// zudojs:route-imports:start" and "// zudojs:route-imports:end" (the markers are missing, so the file was left unchanged) - src/modules/scheduling/routes/index.ts: add "registerEnquiriesRoutes(router, deps.enquiriesController);" between "// zudojs:routes:start" and "// zudojs:routes:end" (the markers are missing, so the file was left unchanged) echo $? 0
Exit code 0, npm run typecheck passes, and tests/enquiries.test.ts passes because it builds its own router. The running server answers 404 on /api/v1/enquiries. How to spot it: the word Warning at the end of the run, and a smoke test that builds the router the way server.ts does, from the anatomy lesson. With /api/v1/enquiries added to it:
npm test … FAIL tests/smoke.test.ts > wiring > serves every route the app should have AssertionError: Expected status 200, got 404. request: GET /api/v1/enquiries response: 404 Not Found body: {"error":"Not Found","method":"GET","path":"/api/v1/enquiries"}
The fix: put the four marker lines back, and add the two lines the warning printed between them. A later generate resource enquiries --force then finds them already present and changes nothing in that file.
Problem 4: unexpected errors vanish
The generated dispatch in src/server.ts turns exposed 4xx errors into responses and rethrows everything else, so the client gets a bare 500. Nothing logs the error. In production that is a failure nobody can see: the database is down, every booking fails, and the logs are clean. How to spot it in your project: open src/server.ts and look for any logging inside dispatch's catch; there is none. The fix is to move dispatch into its own file, where it can be tested, and give it a logger.
errorResponse in the generated src/utils/http.ts (shown in Step 6) decides which errors become responses. The new file:
import type { HttpMiddleware, HttpRouter } from "@zudojs/http";
import type { Logger } from "@zudojs/logger";
import { errorResponse } from "./http.js";
/**
* The last middleware: runs the router and turns exposed 4xx errors into
* responses. Anything else is logged, then rethrown so the server answers a
* generic 500 that reveals nothing to the client.
*/
export function createDispatch(router: HttpRouter, logger: Logger): HttpMiddleware {
return async (context) => {
try {
return (await router.dispatch(context.request, { signal: context.signal })).response;
} catch (error) {
const response = errorResponse(error);
if (response !== undefined) return response;
logger.error("Unhandled error while serving a request", {
method: context.request.method,
path: context.request.path,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
};
}
In src/server.ts, the eight-line dispatch becomes const dispatch = createDispatch(router, createLogger({ name: "bookings-api" }));. Here it is with a repository whose database connection fails, and a hall that does not exist:
import { NotFoundError } from "@zudojs/errors";
import { HttpMiddlewarePipeline, createRouter } from "@zudojs/http";
import { createLogger } from "@zudojs/logger";
import { createHttpTestClient } from "@zudojs/testing";
import { createDispatch } from "./src/utils/dispatch.js";
const router = createRouter();
router.get("/api/v1/bookings", async () => {
throw new Error("connection refused: 10.0.0.5:5432");
});
router.get("/api/v1/halls/:id", async () => {
throw new NotFoundError("No such hall.");
});
const dispatch = createDispatch(router, createLogger({ name: "bookings-api" }));
const client = createHttpTestClient(new HttpMiddlewarePipeline({ middlewares: [dispatch] }));
for (const path of ["/api/v1/halls/abc", "/api/v1/bookings"]) {
const response = await client.get(path);
console.log(response.status, response.text);
}
await client.close();
npx tsx dispatch-check.ts404 {"error":"No such hall.","code":"ERR_RESOURCE_NOT_FOUND"}
2026-09-25T00:02:54.438Z [ERROR] [bookings-api] Unhandled error while serving a request method=GET path=/api/v1/bookings error="connection refused: 10.0.0.5:5432"
500 {"error":"Internal Server Error"}The client learns nothing about the internal address; the operator gets the method, the path and the real message. The expected 404 stays quiet, because logging every normal "not found" buries the real problems.
Problem 5: two readings of NODE_ENV
loadConfig stores NODE_ENV in config.nodeEnv, but the generated src/app.ts ignores it and calls resolveEnvironment(), which reads process.env directly. In the running server both usually see the same variable. In a test, or anywhere you pass loadConfig an explicit environment, they disagree. How to spot it: search src/app.ts for resolveEnvironment() with empty parentheses. resolveEnvironment from @zudojs/constants accepts the environment to read:
import { resolveEnvironment } from "@zudojs/constants";
// What loadConfig({ NODE_ENV: "production" }) stores:
const config = { nodeEnv: "production" };
console.log("generated app.ts:", resolveEnvironment());
console.log("fixed app.ts: ", resolveEnvironment({ NODE_ENV: config.nodeEnv }));
console.log("short form: ", resolveEnvironment({ NODE_ENV: "prod" }));
try {
resolveEnvironment({ NODE_ENV: "prodution" }, { strict: true });
} catch (error) {
console.log("typo, strict: ", (error as Error).name);
}
npx tsx environment-check.tsgenerated app.ts: development fixed app.ts: production short form: production typo, strict: InvalidConstantError
The fix is one line in src/app.ts: environment: resolveEnvironment({ NODE_ENV: options.config.nodeEnv }). A test pins it down, and without the fix it fails in a revealing way, because Vitest sets NODE_ENV=test itself:
npx vitest run tests/config.test.ts … FAIL tests/config.test.ts > configuration > gives the runtime the NODE_ENV that loadConfig read AssertionError: expected 'test' to be 'production' // Object.is equality
| # | Problem | How to spot it | Fix |
|---|---|---|---|
| 1 | generate service --module m creates a new module | Mapping "service" → "module"; paths outside src/modules/m/ | Undo; write the service in src/modules/m/services/ or generate a controller |
| 2 | The validator stub returns true | return true; in validators/; a test with bad input | Write the real check; use it in the DTO |
| 3 | Missing markers only warn, exit 0 | Warning: Finish by hand; a smoke test through registerRoutes | Restore the markers, add the printed lines |
| 4 | dispatch hides 500s | No logging in dispatch's catch | createDispatch(router, logger) with a test |
| 5 | createApp ignores config.nodeEnv | resolveEnvironment() with no argument | Pass { NODE_ENV: options.config.nodeEnv } |
Step 9: the tests that matter
There is no zudojs generate test. The generated test for each resource is a good CRUD check on its own router; everything that makes this product correct is yours to test. Here is the bookings test, rewritten for the real rules, with a fixed "today" so it passes on every day of the year:
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createRouter } from "@zudojs/http";
import { createHttpTestClient } from "@zudojs/testing";
import { BookingsController } from "../src/modules/scheduling/controllers/bookings.controller.js";
import { InMemoryBookingsRepository } from "../src/modules/scheduling/repositories/bookings.repository.js";
import { InMemoryHallsRepository } from "../src/modules/scheduling/repositories/halls.repository.js";
import { registerBookingsRoutes } from "../src/modules/scheduling/routes/bookings.routes.js";
import { AvailabilityService } from "../src/modules/scheduling/services/availability.service.js";
import { BookingsService } from "../src/modules/scheduling/services/bookings.service.js";
const halls = new InMemoryHallsRepository();
const bookings = new InMemoryBookingsRepository();
// A fixed "today", so the test gives the same answer on every day of the year.
const availability = new AvailabilityService(halls, bookings, () => "2026-10-01");
const router = createRouter();
registerBookingsRoutes(router, new BookingsController(new BookingsService(bookings, availability)));
const client = createHttpTestClient(router);
afterAll(() => client.close());
let hallId = "";
beforeAll(async () => {
hallId = (await halls.create({ name: "Eko Hall", capacity: 200, dailyRateKobo: 50_000_000 })).id;
});
const booking = (overrides: Record<string, unknown> = {}) => ({
hallId,
date: "2026-12-24",
customerEmail: " Ada@Example.com ",
guests: 150,
...overrides,
});
describe("/api/v1/bookings", () => {
it("books a free hall at the hall's price", async () => {
await client
.post("/api/v1/bookings")
.send(booking())
.expect(201)
.expectJson({ customerEmail: "ada@example.com", amountKobo: 50_000_000 });
});
it("refuses a second booking of the same hall and date", async () => {
await client.post("/api/v1/bookings").send(booking()).expect(409);
});
it("lets only one of two simultaneous bookings win", async () => {
const service = new BookingsService(bookings, availability);
const input = { hallId, date: "2026-12-31", customerEmail: "ada@example.com", guests: 10 };
const both = await Promise.allSettled([service.create(input), service.create(input)]);
expect(both.map((result) => result.status).sort()).toEqual(["fulfilled", "rejected"]);
});
it("refuses bad input with 400 and an unknown hall with 404", async () => {
await client.post("/api/v1/bookings").send(booking({ guests: 201, date: "2027-01-02" })).expect(400);
await client.post("/api/v1/bookings").send(booking({ date: "2026-02-30" })).expect(400);
await client.post("/api/v1/bookings").send(booking({ date: "2026-09-30" })).expect(400);
await client.post("/api/v1/bookings").send(booking({ hallId: crypto.randomUUID() })).expect(404);
});
});
The race test calls the service directly with Promise.allSettled, because two requests through the test client happened not to interleave, and a test that only sometimes exercises the race proves nothing. With the await version of the repository it fails every time:
npx vitest run tests/bookings.test.ts … FAIL tests/bookings.test.ts > /api/v1/bookings > lets only one of two simultaneous bookings win AssertionError: expected [ 'fulfilled', 'fulfilled' ] to deeply equal [ 'fulfilled', 'rejected' ]
The smoke test and the dispatch test are short:
import { afterAll, describe, it } from "vitest";
import { createRouter } from "@zudojs/http";
import { createHttpTestClient } from "@zudojs/testing";
import { createDependencies } from "../src/container.js";
import { registerRoutes } from "../src/routes/index.js";
// The router exactly as server.ts builds it: this catches lost wiring.
const router = createRouter();
registerRoutes(router, createDependencies());
const client = createHttpTestClient(router);
afterAll(() => client.close());
describe("wiring", () => {
it("serves every route the app should have", async () => {
await client.get("/health").expect(200);
await client.get("/api/v1/bookings").expect(200);
await client.get("/api/v1/enquiries").expect(200);
const hall = await client
.post("/api/v1/halls")
.send({ name: "Smoke Hall", capacity: 10, dailyRateKobo: 1_000_000 })
.expect(201);
const { id } = hall.json<{ id: string }>();
await client.get(`/api/v1/halls/${id}/availability?date=2099-01-01`).expect(200);
});
});
import { afterAll, describe, expect, it } from "vitest";
import { HttpMiddlewarePipeline, createRouter } from "@zudojs/http";
import { createHttpTestClient, createSpyLogger } from "@zudojs/testing";
import { NotFoundError } from "@zudojs/errors";
import { createDispatch } from "../src/utils/dispatch.js";
const logger = createSpyLogger("bookings-api");
const router = createRouter();
router.get("/broken", async () => {
throw new Error("connection refused: 10.0.0.5:5432");
});
router.get("/missing", async () => {
throw new NotFoundError("No such booking.");
});
const client = createHttpTestClient(new HttpMiddlewarePipeline({ middlewares: [createDispatch(router, logger)] }));
afterAll(() => client.close());
describe("dispatch", () => {
it("answers 500 without details, and logs the real error", async () => {
const response = await client.get("/broken").expect(500);
expect(response.text).not.toContain("10.0.0.5");
expect(logger.findByMethod("error")).toHaveLength(1);
expect(logger.calls[0]?.metadata).toMatchObject({ path: "/broken", error: "connection refused: 10.0.0.5:5432" });
});
it("does not log expected 4xx answers", async () => {
logger.clear();
await client.get("/missing").expect(404);
expect(logger.calls).toHaveLength(0);
});
});
createSpyLogger from @zudojs/testing is a Logger that records its calls instead of printing them, so the test can assert on what was logged. With tests/config.test.ts from Step 8 and the two updated generated tests, the whole suite:
npm run typecheck && npm run typecheck:tests && npx vitest run --reporter=verbose … ✓ tests/config.test.ts > configuration > gives the runtime the NODE_ENV that loadConfig read 181ms ✓ tests/dispatch.test.ts > dispatch > answers 500 without details, and logs the real error 572ms ✓ tests/dispatch.test.ts > dispatch > does not log expected 4xx answers 27ms ✓ tests/halls.test.ts > /api/v1/halls > creates, reads, updates and deletes a hall 399ms ✓ tests/halls.test.ts > /api/v1/halls > rejects an invalid body with 400 14ms ✓ tests/halls.test.ts > /api/v1/halls > rejects an id that is not a UUID with 400 5ms ✓ tests/enquiries.test.ts > /api/v1/enquiries > creates, reads, updates and deletes an enquiry 239ms ✓ tests/enquiries.test.ts > /api/v1/enquiries > rejects an invalid body with 400 16ms ✓ tests/enquiries.test.ts > /api/v1/enquiries > rejects an id that is not a UUID with 400 11ms ✓ tests/smoke.test.ts > wiring > serves every route the app should have 573ms ✓ tests/bookings.test.ts > /api/v1/bookings > books a free hall at the hall's price 140ms ✓ tests/bookings.test.ts > /api/v1/bookings > refuses a second booking of the same hall and date 30ms ✓ tests/bookings.test.ts > /api/v1/bookings > lets only one of two simultaneous bookings win 5ms ✓ tests/bookings.test.ts > /api/v1/bookings > refuses bad input with 400 and an unknown hall with 404 73ms Test Files 6 passed (6) Tests 14 passed (14)
Each of the new tests was checked the honest way: break the code it protects, watch it fail, restore the code. A test you have never seen fail may not be testing anything.
Step 10: build and prepare the deployment
Build
zudojs build Building project at: ~/code/bookings-api > bookings-api@0.1.0 build > tsc Build completed successfully. NODE_ENV=production PORT=4200 CORS_ORIGINS=https://bookings.example.ng npm start > bookings-api@0.1.0 start > node dist/server.js 2026-09-24T23:47:48.120Z [INFO] [bookings-api] scheduling module initialized 2026-09-24T23:47:48.125Z [INFO] [bookings-api] All modules initialized. modules=["integrations","scheduling"] durationMs=13 2026-09-24T23:47:48.130Z [INFO] [bookings-api] All modules started. modules=["integrations","scheduling"] durationMs=1 2026-09-24T23:47:48.133Z [INFO] [bookings-api] Runtime is ready. runtimeId=rt_00e7f4e8e0a84b12aec50759096c8d56 environment=production Listening on http://0.0.0.0:4200
zudojs build is npm run build, which is tsc: 53 JavaScript files in dist/, tests excluded. npm start runs them with plain Node.js, no tsx. The log says environment=production, which is now true in both readings of NODE_ENV.
What the CLI does not do
zudojs deploy Command "deploy" was not found. Run "zudojs --help" to see all commands. echo $? 3 zudojs migrate Command "migrate" was not found. Run "zudojs --help" to see all commands.
There is no deploy and no migrate command. Migrations arrive with the database feature: zudojs add database adds Prisma and the scripts db:generate, db:migrate (prisma migrate dev, for your machine) and db:deploy (prisma migrate deploy, for production), and changes build to prisma generate && tsc. Deployment itself is what Deploying a ZudoJS app teaches. What the CLI can give you is the container recipe:
zudojs add docker Adding feature: docker — Dockerfile (multi-stage, non-root, Node 24), .dockerignore and compose.yaml Created: - .dockerignore - Dockerfile - compose.yaml … Feature "docker" added successfully. Next: cp .env.example .env, then: docker compose up --build
# syntax=docker/dockerfile:1
FROM node:24-alpine AS build
WORKDIR /app
# The lockfile is optional (copied with a wildcard): with it the install is
# frozen and the image reproducible; with no lockfile yet (nothing has been
# installed) dependencies resolve from package.json. Commit the lockfile.
COPY package.json package-lock.json* ./
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
RUN npm prune --omit=dev --ignore-scripts
FROM node:24-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build --chown=node:node /app/package.json ./
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s CMD ["node", "-e", "fetch('http://127.0.0.1:3000/health').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
CMD ["node", "dist/server.js"]
Read it as the deployment lesson taught you: a multi-stage build (compile in one image, copy only dist/ and production dependencies into a second), npm ci from the lockfile, a non-root node user, and a HEALTHCHECK that calls /health. Build it, run it, stop it:
docker build -t bookings-api:0.1.0 . … #18 naming to docker.io/library/bookings-api:0.1.0 0.0s done docker run -d --name bookings-api -p 4300:3000 -e CORS_ORIGINS=https://bookings.example.ng bookings-api:0.1.0 0b2bb701d8add1fdc91d7fa3cc091b6eda5d0eb8aeea7cd6b27a5d8f1efcf796 curl -s localhost:4300/health {"status":"ok","checks":{},"timestamp":"2026-09-25T00:09:49.297Z"} docker stop bookings-api bookings-api docker logs bookings-api | tail -3 2026-09-25T00:09:49.354Z [INFO] [bookings-api] All modules destroyed. durationMs=1 2026-09-25T00:09:49.354Z [INFO] [bookings-api] Graceful shutdown complete. 2026-09-25T00:09:49.355Z [INFO] [bookings-api] Runtime stopped. runtimeId=rt_76afc77548ec41e19d4561649b7263cc docker images bookings-api --format '{{.Repository}}:{{.Tag}} {{.Size}}' bookings-api:0.1.0 182MB
docker stop sends SIGTERM, and the log shows the same graceful shutdown you saw in development. tsconfig.test.json and tests/ never reach the image, because the Dockerfile copies only src and tsconfig.json.
Before the first real customer
- Storage. Every repository is still in memory: a restart empties it and two containers disagree. Run
zudojs add database, write a Prisma or SQL repository that implements the same interface withUNIQUE (hall_id, date), swap it insrc/container.ts, and rundb:deployin your release step. Services, controllers and tests keep working. - The docs page.
/docsanswered 200 in production. If the API is not public, change the mount insrc/server.tstomountOpenAPI(router, { info: { … }, docsPath: config.nodeEnv === "production" ? false : "/docs" }): in the rebuilt app/docsanswered 404 and/openapi.jsonstill 200. Exercise 2 protects it with a key instead. - Configuration. Set
NODE_ENV=productionandCORS_ORIGINSto the website's origin; the generated default is no browser origin at all. Never bake.envinto the image (the generated.dockerignoreexcludes it). - Rate limiting counts per process, as the anatomy lesson noted; behind several containers use a shared store.
- A pipeline that runs, in order and stopping at the first failure:
npm ci,npm run typecheck,npm run typecheck:tests,npm test,zudojs build,docker build. The CI/CD lesson turns that list into GitHub Actions.
Practice
TRY IT YOURSELF
No cancellations in the last week
The company keeps the deposit when a customer cancels less than 7 days before the date. Write checkCancellation(date, today) that throws a 409 ConflictError when the booking is fewer than 7 days away, and say where in the project it belongs.
Show a solution
import { ConflictError } from "@zudojs/errors";
const DAY_MS = 86_400_000;
export function checkCancellation(date: string, today: string): void {
const daysAway = (Date.parse(`${date}T00:00:00Z`) - Date.parse(`${today}T00:00:00Z`)) / DAY_MS;
if (daysAway < 7) {
throw new ConflictError(`Bookings on ${date} can no longer be cancelled (${daysAway} days away).`);
}
}
for (const date of ["2026-12-24", "2026-10-08", "2026-10-07"]) {
try {
checkCancellation(date, "2026-10-01");
console.log(date, "can be cancelled");
} catch (error) {
console.log(date, (error as ConflictError).statusCode, (error as Error).message);
}
}
npx tsx cancellation.ts2026-12-24 can be cancelled 2026-10-08 can be cancelled 2026-10-07 409 Bookings on 2026-10-07 can no longer be cancelled (6 days away).
It belongs in BookingsService.remove: load the booking, call checkCancellation(existing.date, today()), then delete. Pass the same replaceable today function the availability service uses, so the test can pin the day, and add a test for exactly 7 and exactly 6 days: boundaries are where off-by-one bugs live. Both dates are parsed as UTC midnight, so no time zone or daylight-saving change can make a day 23 hours long.
TRY IT YOURSELF
Protect the docs page with a key
Instead of hiding /docs in production, require a header x-docs-key that matches a secret from the configuration. mountOpenAPI has a middleware option. Compare the key in constant time.
Show a solution
import { timingSafeEqualString } from "@zudojs/crypto";
import { createResponseContext, createRouter, mountOpenAPI, type HttpMiddleware } from "@zudojs/http";
import { createHttpTestClient } from "@zudojs/testing";
function requireDocsKey(expected: string): HttpMiddleware {
return async (context, next) => {
const given = context.request.getHeader("x-docs-key") ?? "";
if (expected === "" || !timingSafeEqualString(given, expected)) {
return createResponseContext({ status: 401 }).json({ error: "A valid x-docs-key header is required." });
}
return next();
};
}
const docsKey = "set-me-from-the-environment"; // in the app: config.docsKey, read by loadConfig
const router = createRouter();
router.get("/api/v1/halls", async () => createResponseContext().json([]));
mountOpenAPI(router, { info: { title: "bookings-api", version: "0.1.0" }, middleware: [requireDocsKey(docsKey)] });
const client = createHttpTestClient(router);
console.log((await client.get("/docs")).status, (await client.get("/openapi.json")).status);
console.log((await client.get("/docs").set("x-docs-key", "guess")).status);
console.log((await client.get("/openapi.json").set("x-docs-key", docsKey)).status);
console.log((await client.get("/api/v1/halls")).status);
await client.close();
npx tsx docs-key.ts401 401 401 200 200
The middleware guards only the routes mountOpenAPI registers, so the API itself is untouched. An empty configured key refuses everyone rather than accepting an empty header. A plain === would stop at the first wrong character, and the time that takes can leak how much of a guess was right; timingSafeEqualString takes the same time either way. In the app, add a docsKey setting to loadConfig and keep it in the environment, never in the code.
TRY IT YOURSELF
A command from the generator
The owners want an explicit "confirm booking" step later. Predict what zudojs generate command confirm-booking --module scheduling writes, and what it leaves for you. Then run the generated handler through a command bus.
Show a solution
zudojs generate command confirm-booking --module scheduling Detected architecture: modular-monolith Generated 3 files: - src/modules/scheduling/commands/confirm-booking/confirm-booking.command.ts - src/modules/scheduling/commands/confirm-booking/confirm-booking.handler.ts - src/modules/scheduling/commands/confirm-booking/index.ts
Three files inside the module, and no edits anywhere else: no route, no container entry, and no command bus is created for you. @zudojs/cqrs is already in package.json (every create adds it), so nothing else changes. These are the two files it wrote:
import { createCommand, type CommandOf } from "@zudojs/cqrs";
/** Discriminator the command bus routes ConfirmBooking commands on. */
export const CONFIRM_BOOKING_COMMAND = "ConfirmBooking";
/** Data a ConfirmBooking command carries. Replace it with the fields the use case needs. */
export type ConfirmBookingCommandPayload = {
readonly data: Readonly<Record<string, unknown>>;
};
/** The ConfirmBooking command: `{ type: "ConfirmBooking", data }`. */
export type ConfirmBookingCommand = CommandOf<typeof CONFIRM_BOOKING_COMMAND, ConfirmBookingCommandPayload>;
/** Creates an immutable ConfirmBooking command. */
export function createConfirmBookingCommand(payload: ConfirmBookingCommandPayload): ConfirmBookingCommand {
return createCommand(CONFIRM_BOOKING_COMMAND, payload);
}
import { randomUUID } from "node:crypto";
import { CommandHandler, type CommandBus } from "@zudojs/cqrs";
import { CONFIRM_BOOKING_COMMAND, type ConfirmBookingCommand } from "./confirm-booking.command.js";
/** What executing a ConfirmBooking command returns. */
export interface ConfirmBookingCommandResult {
readonly id: string;
readonly data: Readonly<Record<string, unknown>>;
}
/** Handles ConfirmBooking commands: put the write-side logic in `execute`. */
export class ConfirmBookingCommandHandler extends CommandHandler<ConfirmBookingCommand, ConfirmBookingCommandResult> {
public override readonly commandType = CONFIRM_BOOKING_COMMAND;
public override async execute(command: ConfirmBookingCommand): Promise<ConfirmBookingCommandResult> {
return { id: randomUUID(), data: command.data };
}
}
/**
* Registers {@link ConfirmBookingCommandHandler} on a command bus.
*
* @example
* const bus = registerConfirmBookingCommand(createCommandBus());
* const result = await bus.execute<ConfirmBookingCommand, ConfirmBookingCommandResult>(
* createConfirmBookingCommand({ data: {} }),
* );
*/
export function registerConfirmBookingCommand(bus: CommandBus): CommandBus {
return bus.register(CONFIRM_BOOKING_COMMAND, new ConfirmBookingCommandHandler());
}
import { createCommandBus } from "@zudojs/cqrs";
import { createConfirmBookingCommand, type ConfirmBookingCommand } from "./src/modules/scheduling/commands/confirm-booking/confirm-booking.command.js";
import {
registerConfirmBookingCommand,
type ConfirmBookingCommandResult,
} from "./src/modules/scheduling/commands/confirm-booking/confirm-booking.handler.js";
const bus = registerConfirmBookingCommand(createCommandBus());
const command = createConfirmBookingCommand({ data: { bookingId: "not-even-a-uuid", paid: "maybe" } });
const result = await bus.execute<ConfirmBookingCommand, ConfirmBookingCommandResult>(command);
console.log(command.type, Object.isFrozen(command));
console.log(typeof result.id, result.data);
npx tsx command-check.tsConfirmBooking true
string { bookingId: 'not-even-a-uuid', paid: 'maybe' }It runs, and it is another stub: the payload is "any record", so a nonsense booking id and paid: "maybe" go straight through, and the handler invents a fresh id instead of confirming anything. Before using it, replace data with real fields (bookingId, paymentReference), validate them with a schema, and give the handler the BookingsRepository through its constructor at the composition root. The type arguments of bus.execute<…> are only a promise you make to the compiler; the CQRS lesson shows why a test is what really checks the result.
Summary
- The CLI created the project, the module, the resources, the validator and the Dockerfile; it ran your dev server and your build. Commit before every
generateand read the diff after it. - Generated resources are skeletons. You wrote the rules: schemas with real fields, a validator that validates, a repository that refuses double bookings without a race, and a hand-written service shared at the composition root, outside the markers.
- Five known problems, each with a way to spot it:
servicebecomes a module in a modular monolith; the validator stub returnstrue; missing markers only warn;dispatchhides 500s;createAppignoresconfig.nodeEnv. - Tests are not type-checked by the generated config, and the generated tests cannot see wiring, races, logging or configuration.
tsconfig.test.json, a smoke test, a race test, a dispatch test and a config test close those gaps. - There is no
deploy,migrateortestcommand.zudojs add dockerwrites a multi-stage Dockerfile; migrations come withzudojs add databaseas npm scripts; the pipeline and the release are yours.
Next, Capstone: ShopFlow builds a larger application from ZudoJS packages directly, with a transactional checkout on PostgreSQL.
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.