Command-query separation and CQRS-light
Ask without changing, change without asking: queries read the tables straight, and commands go through the model that keeps the rules.
Predict first
Before you read: predict
In this lesson's model of ExamHall, the proctor dashboard lists every flagged attempt with the candidate's name, the attempt's status and its number of flags. Today it loads each flagged Attempt through AttemptRepository (its row, its flags and its candidate) and maps it to a row: with 3 flagged attempts, a refresh sends 10 SQL statements. Attempt.submit() refuses a submit after the attempt's deadline. The team wants the dashboard to stop loading Attempt aggregates, while a late submit is still refused and an attempt submitted a moment ago shows as submitted on the next refresh.
Which change does all three?
The 30-second version
Command-query separation sorts an object's methods: a command changes state and returns nothing; a query returns a value and changes nothing a caller can see. CQRS-light makes the same cut per use case, in one database: commands go through the domain model that holds the rules, and queries read SQL shaped for one screen.
Ask without changing, change without asking: queries read the tables straight, and commands go through the model that keeps the rules.
How it works
Step 1 of 14
In the kitchen
- "is the hall free on Saturday?" is answered from the diary, and asking books nothing → a query changes nothing (steps 1–2)
Analogy: A village hall's booking secretary
The hall keeps one booking diary; the secretary takes bookings, and the caretaker works from the same diary.
| At the village hall | In code |
|---|---|
| the secretary takes a booking only after checking the hirer's insurance and that it clashes with nothing | a command goes through the model that checks the rules (step 8) |
| "is the hall free on Saturday?" is answered from the diary, and asking books nothing | a query changes nothing (steps 1–2) |
| a secretary who, asked whether Saturday is free, pencils the caller in | a mixed method (step 3) |
| the caretaker's week sheet copies date, room and chair layout from the diary, never the insurance papers | a query shaped for its screen (steps 8–9) |
| a booking taken in the morning is on that afternoon's sheet, because the sheet is read from the diary itself | one database, no copy (step 10) |
In real terms: the diary is the database; the secretary is the command side with its checks, and every sheet read from the diary is a query.
Breaks down when: the caretaker can read a booking half written in pencil; a query sees only what a transaction has committed.
Contrasting case: the parish newsletter's "what's on" list, set from the diary once a month: a second copy that is always behind (the heavy form, ds-cqrs-event-sourcing).
Examples, run for real
4.1 ExamHall: the proctor dashboard
EXH-4890: on the morning of a large ECON-201 sitting the proctor dashboard grew slow. It loaded every flagged attempt through AttemptRepository (its row, its flags and its candidate) and mapped each to a row.
MR !5190 adds FlaggedAttemptsQuery, one statement returning the dashboard's rows; SubmitAttemptCommand still loads the Attempt and calls submit():
SELECT a.id, c.name, a.status, count(f.id)::int AS flags
FROM attempts a JOIN candidates c ON c.id = a.candidate_id JOIN flags f ON f.attempt_id = a.id
GROUP BY a.id, c.name, a.status ORDER BY a.id
4.1 EXH-4890 through AttemptRepository: 3 flagged attempts 10 statements per refresh · 30 flagged attempts 91
4.1 MR !5190 FlaggedAttemptsQuery: 3 flagged attempts 1 statement per refresh · 30 flagged attempts 1 · same rows as before: yes, yes
The rows are the same; the statements per refresh no longer grow with the flags (steps 8–9).
4.2 The same pair on @nestjs/cqrs
@CommandHandler(SubmitAttemptCommand)
export class SubmitAttemptHandler implements ICommandHandler<SubmitAttemptCommand> {
async execute(c: SubmitAttemptCommand) {
const attempt = await this.attempts.load(c.attemptId);
attempt.submit(c.minute);
await this.attempts.save(attempt);
}
}
@QueryHandler(FlaggedAttemptsQuery)
export class FlaggedAttemptsHandler implements IQueryHandler<FlaggedAttemptsQuery> {
execute() { return this.db.query(FLAGGED_SQL).then((r) => r.rows); }
}
4.2 SubmitAttemptCommand a-11 at minute 41: submitted
4.2 SubmitAttemptCommand a-12 at minute 95: DeadlinePassed
4.2 FlaggedAttemptsQuery: 3 rows in 1 statement · a-11 submitted, a-12 open, a-13 open
A caller hands a SubmitAttemptCommand or a FlaggedAttemptsQuery to its bus and never names a handler (step 11).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 6
A box office holds seats while customers pay; a job releases the seats whose holds have expired. In main.ts the job runs at minute 200 and again at minute 300. job.ts comes in three versions; holds.ts and main.ts are the same in every version. The files are below.
Which version makes main.ts print released A1, A2, A3, A4, each expired seat once?
// holds.tsexport type Hold = { seat: string; expiresAt: number };export class HoldQueue { private holds: Hold[] = []; add(hold: Hold): void { this.holds.push(hold); this.holds.sort((a, b) => a.expiresAt - b.expiresAt); } /** The earliest hold that has expired by `now`, taken off the queue. */ takeExpired(now: number): Hold | undefined { return this.holds.length > 0 && this.holds[0].expiresAt <= now ? this.holds.shift() : undefined; } /** Every hold that has expired by `now`. */ expired(now: number): Hold[] { return this.holds.filter((h) => h.expiresAt <= now); }} // job.ts (version 1)import type { HoldQueue } from './holds';export function releaseExpired(queue: HoldQueue, now: number, release: (seat: string) => void): void { while (queue.takeExpired(now)) { const hold = queue.takeExpired(now); if (hold) release(hold.seat); }} // job.ts (version 2)import type { HoldQueue } from './holds';export function releaseExpired(queue: HoldQueue, now: number, release: (seat: string) => void): void { for (let hold = queue.takeExpired(now); hold; hold = queue.takeExpired(now)) release(hold.seat);} // job.ts (version 3)import type { HoldQueue } from './holds';export function releaseExpired(queue: HoldQueue, now: number, release: (seat: string) => void): void { for (const hold of queue.expired(now)) release(hold.seat);} // main.tsimport { HoldQueue } from './holds';import { releaseExpired } from './job';const queue = new HoldQueue();queue.add({ seat: 'A1', expiresAt: 100 });queue.add({ seat: 'A2', expiresAt: 110 });queue.add({ seat: 'A3', expiresAt: 120 });queue.add({ seat: 'A4', expiresAt: 250 });queue.add({ seat: 'A5', expiresAt: 900 });const released: string[] = [];releaseExpired(queue, 200, (seat) => released.push(seat));releaseExpired(queue, 300, (seat) => released.push(seat));console.log(`released ${released.join(', ')}`);Question 2 of 6
A box office lets one customer hold at most 4 seats per show. Show s-1's seats today: c-7 holds A2, A3, A4 and A5; c-8 holds A1; B5 and B6 are free. db.ts is the box office's Postgres client (not shown; db.query(sql, params) returns { rows, rowCount }). reserve.ts comes in three versions; the other files are the same in every version. The files are below.
main() runs once.
With which version does it print B5 for c-7: TooManySeats, then A1 for c-9: SeatTaken, then B6 for c-8: reserved?
// show.tsimport { db } from './db';export class SeatTaken extends Error {}export class TooManySeats extends Error {}type SeatRow = { seat: string; holder: string | null };export class Show { constructor(readonly id: string, private readonly seats: SeatRow[]) {} reserve(seat: string, customer: string): void { const s = this.seats.find((x) => x.seat === seat); if (!s || s.holder !== null) throw new SeatTaken(); if (this.seats.filter((x) => x.holder === customer).length >= 4) throw new TooManySeats(); s.holder = customer; } holderOf(seat: string): string | null { return this.seats.find((x) => x.seat === seat)?.holder ?? null; }}export const shows = { async load(id: string): Promise<Show> { const r = await db.query<SeatRow>('SELECT seat, holder FROM seats WHERE show_id = $1 ORDER BY seat', [id]); return new Show(id, r.rows); }, async save(show: Show, seat: string): Promise<void> { await db.query('UPDATE seats SET holder = $3 WHERE show_id = $1 AND seat = $2', [show.id, seat, show.holderOf(seat)]); },}; // seat-map.ts (the seat-map screen's query)import { db } from './db';export async function seatMap(showId: string): Promise<{ seat: string; free: boolean }[]> { const r = await db.query<{ seat: string; free: boolean }>('SELECT seat, holder IS NULL AS free FROM seats WHERE show_id = $1 ORDER BY seat', [showId]); return r.rows;} // reserve.ts (version 1)import { shows } from './show';export async function reserveSeat(showId: string, seat: string, customer: string): Promise<void> { const show = await shows.load(showId); show.reserve(seat, customer); await shows.save(show, seat);} // reserve.ts (version 2)import { db } from './db';import { Show, shows } from './show';export async function reserveSeat(showId: string, seat: string, customer: string): Promise<void> { const r = await db.query<{ seat: string; holder: string | null }>('SELECT seat, holder FROM seats WHERE show_id = $1 AND seat = $2', [showId, seat]); const show = new Show(showId, r.rows); show.reserve(seat, customer); await shows.save(show, seat);} // reserve.ts (version 3)import { db } from './db';import { SeatTaken, TooManySeats } from './show';import { seatMap } from './seat-map';export async function reserveSeat(showId: string, seat: string, customer: string): Promise<void> { const map = await seatMap(showId); if (!map.find((s) => s.seat === seat)?.free) throw new SeatTaken(); if (map.filter((s) => !s.free).length >= 4) throw new TooManySeats(); await db.query('UPDATE seats SET holder = $3 WHERE show_id = $1 AND seat = $2', [showId, seat, customer]);} // main.tsimport { reserveSeat } from './reserve';export async function main(): Promise<void> { for (const [seat, customer] of [['B5', 'c-7'], ['A1', 'c-9'], ['B6', 'c-8']]) { try { await reserveSeat('s-1', seat, customer); console.log(`${seat} for ${customer}: reserved`); } catch (e) { console.log(`${seat} for ${customer}: ${(e as Error).constructor.name}`); } }}Question 3 of 6
A NestJS box office sends seat reservations through @nestjs/cqrs. The admin tool kept its own copy of the command class, written the same way. box-office.ts comes in three versions; the other files are the same in every version. The files are below.
main() runs once.
With which version does it print A1 held for c-7?
// reserve-seat.command.tsexport class ReserveSeatCommand { constructor(readonly showId: string, readonly seat: string, readonly customer: string) {}} // admin/reserve-seat.command.tsexport class ReserveSeatCommand { constructor(readonly showId: string, readonly seat: string, readonly customer: string) {}} // reserve-seat.handler.tsimport { CommandHandler, ICommandHandler } from '@nestjs/cqrs';import { ReserveSeatCommand } from './reserve-seat.command';@CommandHandler(ReserveSeatCommand)export class ReserveSeatHandler implements ICommandHandler<ReserveSeatCommand> { async execute(c: ReserveSeatCommand): Promise<string> { return `${c.seat} held for ${c.customer}`; }} // box-office.ts (version 1)import { CommandBus } from '@nestjs/cqrs';import { ReserveSeatCommand } from './admin/reserve-seat.command';export const reserve = (bus: CommandBus) => bus.execute(new ReserveSeatCommand('s-1', 'A1', 'c-7')); // box-office.ts (version 2)import { CommandBus } from '@nestjs/cqrs';export const reserve = (bus: CommandBus) => bus.execute({ showId: 's-1', seat: 'A1', customer: 'c-7' }); // box-office.ts (version 3)import { CommandBus } from '@nestjs/cqrs';import { ReserveSeatCommand } from './reserve-seat.command';export const reserve = (bus: CommandBus) => bus.execute(new ReserveSeatCommand('s-1', 'A1', 'c-7')); // main.tsimport { Module } from '@nestjs/common';import { NestFactory } from '@nestjs/core';import { CommandBus, CqrsModule } from '@nestjs/cqrs';import { ReserveSeatHandler } from './reserve-seat.handler';import { reserve } from './box-office';@Module({ imports: [CqrsModule.forRoot()], providers: [ReserveSeatHandler] })class AppModule {}export async function main(): Promise<void> { const app = await NestFactory.createApplicationContext(AppModule, { logger: false }); try { console.log(await reserve(app.get(CommandBus))); } catch (e) { console.log((e as Error).constructor.name); } finally { await app.close(); }}Question 4 of 6
In a mock interview you said @nestjs/cqrs earns its place when every command needs the same handling. The interviewer says: "Show me. Every command must leave one audit line, including the ones the expiry scheduler sends." Three candidates answer with audit.ts; the other files are the same for all three. The files are below.
main() runs once and sends three commands.
Which answer prints one audit line for each of them?
// commands.tsimport { CommandHandler, ICommandHandler } from '@nestjs/cqrs';export class ReserveSeatCommand { constructor(readonly seat: string) {} }export class ReleaseHoldCommand { constructor(readonly seat: string) {} }@CommandHandler(ReserveSeatCommand)export class ReserveSeatHandler implements ICommandHandler<ReserveSeatCommand> { async execute(c: ReserveSeatCommand): Promise<void> {}}@CommandHandler(ReleaseHoldCommand)export class ReleaseHoldHandler implements ICommandHandler<ReleaseHoldCommand> { async execute(c: ReleaseHoldCommand): Promise<void> {}} // audit.ts (answer 1)import { Injectable, Module, OnModuleInit } from '@nestjs/common';import { EventBus } from '@nestjs/cqrs';@Injectable()export class Audit implements OnModuleInit { readonly lines: string[] = []; constructor(private readonly events: EventBus) {} onModuleInit(): void { this.events.subscribe((e) => this.lines.push(e.constructor.name)); }}@Module({ providers: [Audit], exports: [Audit] })export class AuditModule {} // audit.ts (answer 2)import { Injectable, Module, OnModuleInit } from '@nestjs/common';import { CommandBus } from '@nestjs/cqrs';@Injectable()export class Audit implements OnModuleInit { readonly lines: string[] = []; constructor(private readonly commands: CommandBus) {} onModuleInit(): void { this.commands.subscribe((c) => this.lines.push(c.constructor.name)); }}@Module({ providers: [Audit], exports: [Audit] })export class AuditModule {} // audit.ts (answer 3)import { CallHandler, ExecutionContext, Injectable, Module, NestInterceptor } from '@nestjs/common';import { APP_INTERCEPTOR } from '@nestjs/core';@Injectable()export class Audit implements NestInterceptor { readonly lines: string[] = []; intercept(context: ExecutionContext, next: CallHandler) { this.lines.push(context.getClass().name); return next.handle(); }}@Module({ providers: [Audit, { provide: APP_INTERCEPTOR, useExisting: Audit }], exports: [Audit] })export class AuditModule {} // main.tsimport { Injectable, Module } from '@nestjs/common';import { NestFactory } from '@nestjs/core';import { CommandBus, CqrsModule } from '@nestjs/cqrs';import { ReleaseHoldCommand, ReleaseHoldHandler, ReserveSeatCommand, ReserveSeatHandler } from './commands';import { Audit, AuditModule } from './audit';@Injectable()class BoxOffice { constructor(readonly bus: CommandBus) {} reserve(seat: string) { return this.bus.execute(new ReserveSeatCommand(seat)); } }@Injectable()class ExpiryScheduler { constructor(readonly bus: CommandBus) {} release(seat: string) { return this.bus.execute(new ReleaseHoldCommand(seat)); } }@Module({ imports: [CqrsModule.forRoot(), AuditModule], providers: [ReserveSeatHandler, ReleaseHoldHandler, BoxOffice, ExpiryScheduler] })class AppModule {}export async function main(): Promise<void> { const app = await NestFactory.createApplicationContext(AppModule, { logger: false }); await app.get(BoxOffice).reserve('A1'); await app.get(ExpiryScheduler).release('B2'); await app.get(ExpiryScheduler).release('B3'); console.log(`audit: ${app.get(Audit).lines.join(', ') || 'none'}`); await app.close();}Question 5 of 6
A payments ledger shows an account's balance on its statement page, which calls balance() whenever it redraws. account.ts comes in three versions; main.ts is the same in every version and prints both answers and whether any of the account's public fields changed. The files are below.
In which version is balance() a query, one the page may call as often as it likes without changing anything a caller of Account can see?
// account.ts (version 1)export class Account { readonly entries: number[] = [12000, -3000, -450]; feeDue = 500; lastBalance: number | null = null; balance(): number { this.lastBalance = this.entries.reduce((sum, e) => sum + e, 0); return this.lastBalance; }} // account.ts (version 2)export class Account { readonly entries: number[] = [12000, -3000, -450]; feeDue = 500; balance(): number { if (this.feeDue > 0) { this.entries.push(-this.feeDue); this.feeDue = 0; } return this.entries.reduce((sum, e) => sum + e, 0); }} // account.ts (version 3)export class Account { readonly entries: number[] = [12000, -3000, -450]; feeDue = 500; #total: number | null = null; balance(): number { if (this.#total === null) this.#total = this.entries.reduce((sum, e) => sum + e, 0); return this.#total; }} // main.tsimport { Account } from './account';const account = new Account();const before = JSON.stringify(account);const first = account.balance();const second = account.balance();console.log(`${first} | ${second} | public fields ${JSON.stringify(account) === before ? 'unchanged' : 'changed'}`);Question 6 of 6
A chat app's room list shows Ana each of her 3 rooms with its number of unread messages: those other members posted after her read marker for that room. Today, in this lesson's model of the app, the list loads each Room through RoomRepository (its row, its messages and Ana's marker) and asks it room.unreadFor('ana'); it shows #general 2, #ops 0 and #random 1. In #general her marker is at minute 10, and Chen posted at minute 12, Ana at 15 and Ben at 20. Ana opens the room list twice, with no new message in between.
Which change shows those counts on both refreshes, loading no Room?
Go deeper
Pro tips
- When: a module has a handful of use cases and nothing every command shares (step 14).
- Let a command return what its caller cannot get otherwise, such as a new id. Fowler keeps
popbecause it is useful;@nestjs/cqrslets a command class declare its handler's result type by extendingCommand<T>, andexecute()resolves to that result. When: the caller must redirect to the record it has just created (steps 5, 11). Verify (RAN,tips.mjs):HoldSeatCommand extends Command<{ holdId: string }>compiles with no diagnostics, andcommandBus.execute(new HoldSeatCommand('A1'))resolved to{"holdId":"h-A1"}.
Source
- Martin Fowler, CommandQuerySeparation (the row's first source): https://martinfowler.com/bliki/CommandQuerySeparation.html · fetched 2026-10-17. Teaching only; every key here is executed. "Queries: Return a result and do not change the observable state of the system (are free of side effects)." and "Commands: Change the state of a system but do not return a value." (step 1); "you can use queries in many situations with much more confidence, introducing them anywhere, changing their order. You have to be more careful with modifiers." (step 2); "the rule about not changing state really only applies to the ObservableState of the system" (step 4); "Popping a stack is a good example of a query that modifies state. Meyer correctly says that you can avoid having this method, but it is a useful idiom." (step 5). Valid as of 2026-10-17.
- Martin Fowler, CQRS (the row's second source): https://martinfowler.com/bliki/CQRS.html · fetched 2026-10-17. "The change that CQRS introduces is to split that conceptual model into separate models for update and display, which it refers to as Command and Query respectively following the vocabulary of CommandQuerySeparation." (step 6); "The in-memory models may share the same database, in which case the database acts as the communication between the two models." (steps 7, 10). The page's warning that "for most systems CQRS adds risky complexity" is the prerequisite's step 2, linked, not re-taught. Valid as of 2026-10-17.
- NestJS docs, CQRS recipe (the row's third source). docs.nestjs.com renders the page with client-side JavaScript, so its source text was fetched from the docs repository at a pinned commit: https://raw.githubusercontent.com/nestjs/docs.nestjs.com/fcc82097df56e6f318960d634c17c2177e17769e/content/recipes/cqrs.md · fetched 2026-10-17. "It dispatches commands to the appropriate handlers. The
execute()method returns a promise that resolves to the value returned by the handler." (step 11); "CommandBus,QueryBus, andEventBusare all Observables." (step 12); "Finally, register theKillDragonHandleras a provider in a module" (step 11); "Extending theCommandclass is optional. You only need it to define the command's return type." (tip 2). Valid as of 2026-10-17 (@nestjs/cqrs12.1.0, the version every NestJS item runs).
Related lessons
- Read first: CQRS and event sourcing (awareness)
- Read first: The repository pattern over an ORM
- Related: Explicit state machines and guarded transitions
- Related: The request pipeline: middleware, guards, interceptors, pipes, handler, filters
- Related: DTOs and mappers at the boundaries
- Related: Replication and read replicas
Checked against the tools and docs as of 2026-10-17.