Why CQRS

The problemThe use case takes five parameters, and nothing stops a call from mixing them up. Reading goes through the same aggregate: to show one row on a screen the service loads the whole invoice with all the rules reading does not need.

The same service as in the DDD chapters: the invoice, its modules and its ports. The domain does not change from here on — what changes is how it is called and how it is read.

  1. Step 1 of 7

    Five parameters and one aggregate

    On the right is the use case from the infrastructure chapter. It works, and it has two problems.

    The first is visible right in its signature: there are already five parameters and there will be seven. Every call spells them out again, nothing stops you from swapping two of the same type, and the call cannot be passed on — into a queue, a log, a retry — because it has no shape.

    The second is not visible in the signature, but it is right there. To show an invoice on a screen the same code loads the whole aggregate: lines, invariants, transaction. For one card in a list that is expensive and pointless — reading needs nothing of what the aggregate exists for.

    Step 2 of 7

    The command as a type

    A command is a request to change state, named by a type. Here it sits next to the use case, in the same module: the command is invented by whoever carries it out.

    What that buys immediately: the call now has a shape. It can be put in a queue, written to a log, retried after a failure and compared with what arrived — there was nothing to compare before.

    Validate checks the shape, not the domain: the number is not empty, there is at least one line. The rules stayed in the domain, and that is the line not to blur — a shape check answers "can this be read at all", not "can such an invoice exist".

    Step 3 of 7

    The command handler

    The same file, with two parameters instead of five: ctx and the command. The use case became a handler: it takes the command, checks the shape, hands the work to the domain, saves, publishes the facts. The transport changed by one line: it builds the command and calls Handle.

    The order has not changed by a single step — what changed is what it is called with. That is why CQRS starts neither with two databases nor with a bus, but with a type: the command separates what is being asked from how it is carried out.

    Step 4 of 7

    The query and its own read model

    A query lives in its own module and knows neither the aggregate nor the write ports. It returns a Card — a projection for the screen: number, total, status, overdue flag.

    That is a DTO, not an aggregate: no invariants, no methods, no rules.

    The request itself is named as a type too — CardQuery, exactly like the command on the write side. It holds one number for now, but it already says what is being asked for: such a request can be logged, cached by key, passed on. Add a filter or a page, and they go into this type rather than into a sixth argument of a method.

    The query has no shape check, and that is not an oversight: a command changes state, so its shape is checked before the domain; a query changes nothing, and the worst it can do is not find the card.

    Reading has its own port — Cards: it takes the query and hands out the card ready to use. Behind that port there can be the same Postgres with one SELECT, a materialized view or a separate database — reading does not care, and the domain cares even less.

    What is missing here is a handler with a Run that would only pass the call along to the port. Reading has neither invariants nor a transaction for such a layer to exist for; the layer appears once it has something to do: checking permissions, joining two sources, holding a cache.

    This module does not touch a single line of the domain: it simply cannot.

    Step 5 of 7

    The reading itself

    Here is the reading: one SELECT and not a single aggregate. The invoice lines are not loaded, no invariants are checked, no facts are recorded — the reader needs none of that.

    The columns land straight in the fields of the projection, because the query is written for the screen rather than for the model. The overdue flag is computed by the query itself — roughly, from the due date and the status; the exact rule lives in the domain and is consulted where decisions are made, not where things are displayed.

    The connection comes from a field rather than from ctx: reading needs no transaction — there is nothing to roll back.

    Behind the port is still the same table the writes go to, and that is a fine first step. A separate read table, a materialized view or a database of its own appear once reading starts getting in the way of writing — and the code above will not change by a line: it has a port.

    Step 6 of 7

    Why this is a split, not two styles

    Writing and reading ask different things of a model. Writing needs invariants and a transaction boundary — the whole aggregate. Reading needs a response shape and speed — a flat table for one specific screen.

    One model serves both badly: it either drags rules where nobody checks them, or spreads into fields only a screen needs.

    The split is cheap: two types and two ports, each in its own module. Behind that boundary you can go as far as you like — a separate read database, a projection updated from a fact on the bus, a cache. Or you can go nowhere at all: the same database, the same SELECT — the split has already done the main thing.

  2. Step 7 of 7

    What we ended up with

    Click a file to read it.

    • applications/issuing/command.go — the command and its shape check.
    • applications/issuing/issue.go — the handler: ctx and the command.
    • applications/issuing/transport/http/handler.go — the transport builds the command.
    • applications/views/card.go — the projection for the screen and the read port.
    • infrastructure/postgres/cards.go — the reading itself: one query for that screen.

    The domain has not changed by a single line. That is the check: if splitting commands and queries required edits in the aggregate, the split was made in the wrong place.

Use ← and → to move between steps.