Lesson 5 · CQRS — commands, queries & the read/write split

CQRS & Fowler's caveat

Separate the model you write with from the model you read with — and know exactly how little of that this repo actually does, on purpose.

Your win: define CQRS honestly, quote Fowler's warning, and describe what this repo's version is — folders and handler types — and, just as importantly, what it is not.

The idea, in one sentence

Martin Fowler: CQRS is "the notion that you can use a different model to update information than the model you use to read information."1 A command changes state (create, update, delete). A query reads state and has no side effects. CQRS says: stop forcing both through one model — give writes their own path and reads their own path.

┌─────────────┐ write ─▶ COMMAND ─▶ │ write model │ ─▶ DB └─────────────┘ ┌─────────────┐ read ─▶ QUERY ─▶ │ read model │ ◀─ DB └─────────────┘

Why split? In a complex domain, the shape that's convenient for validating and saving a change is rarely the shape that's convenient for displaying data — one model doing both ends up doing neither well.

Fowler's caveat — say it in the interview

"CQRS adds risky complexity" The same article is unusually blunt: "for most systems CQRS adds risky complexity" and it "should only be used on specific portions of a system." Fowler has seen it sink projects.1 Quoting this is how you signal seniority: you know CQRS is a scalpel, not a default — reach for it where read and write models genuinely diverge, not everywhere.

What this repo's CQRS actually is

Here's the honest version. In your services, CQRS is just two folders and two handler types:2

internal/spike/modules/email/application/
application/
├── commands/      // WRITE handlers: CreateEmailHandler, BulkCreateEmailsHandler …
├── queries/       // READ handlers:  GetEmailEventHandler …
└── consumers/     // Kafka handlers (shared logic)

A command handler lives in commands/; a query handler in queries/. One operation per handler, one handler per file. notification's system_notification module is the same, and its own docs call it the "✅ canonical — copy this" module.3 That's the whole mechanism.

What it is NOT (so you don't invent it)

Textbook CQRS sometimes has…This repo
a command bus / mediator that routes commands to handlersNone. Controllers call handlers directly (Lesson 8).
event sourcing (rebuild state from an event log)None. Plain rows in postgres.
separate read & write databasesOne DB. The "split" is at the handler layer only.
The mental model This repo's CQRS is the lightweight end of the spectrum — exactly what Three Dots Labs recommend as the sane Go default: split commands and queries into separate types for clarity and testability, and stop there unless you have a concrete reason to go further. No bus, no event store, no second database. If an interviewer asks "do you use a command bus?", the correct answer for this codebase is a confident "no — handlers are called directly."
Read this next

CQRS — Martin Fowler

Short, and the source of both the definition and the caveat. Read the whole thing; it's two screens and it will make you a more honest engineer about this pattern.

martinfowler.com/bliki/CQRS.html · threedots.tech — basic CQRS in Go
→ in-repo internal/spike/modules/email/application/{commands,queries}/

Check yourself (from memory)

Q1. What does CQRS separate?

Commands (writes) get one model/path, queries (reads) another — so neither is compromised by serving both jobs at once.

Q2. How does this repo route a command to its handler?

There is no bus or mediator. Controllers hold the handler and call it directly — the lightweight end of CQRS (Lesson 8 shows the wiring).

Q3. What did Fowler warn about CQRS?

"For most systems CQRS adds risky complexity"; apply it only to specific portions. It's a scalpel, not a default.
Recall: CQRS — the idea, Fowler's caveat, and this repo's version.
definition + warning + what it is / isn't, then reveal
Idea (Fowler): use a different model to write than to read. Command = changes state; Query = reads, no side effects. Caveat: "for most systems CQRS adds risky complexity … use only on specific portions" — a scalpel, not a default (quote it to signal seniority). This repo's version: two folders + two handler types — application/commands/ & application/queries/, one op per handler/file. notification system_notification = the canonical module. What it is NOT: no command bus/mediator (controllers call handlers directly), no event sourcing, no separate read DB. The lightweight end of the spectrum, on purpose.
🎯 Interview one-liner "Do you use CQRS?" → "Yes, the lightweight form — separate command and query handlers, one per operation, no bus and no event sourcing. Fowler's right that full CQRS adds risky complexity, so we keep it to the handler layer for clarity and testability rather than splitting databases. Controllers call the handlers directly."
Next: the write side up close — command handlers, and the two ways this repo scopes a transaction across them. Ask me if the "no bus" point feels like something's missing (it isn't — it's a deliberate choice).

1. Martin Fowler, CQRS (bliki, 2011).

2. In-repo: internal/spike/modules/email/application/{commands,queries}/; internal/spike/CLAUDE.md.

3. In-repo: internal/notification/CLAUDE.md (module table — system_notification "✅ Canonical — copy this").