Lesson 6 · CQRS — commands, queries & the read/write split · your code
Command handlers
The write side up close — one operation, its dependencies as fields, and a transaction wrapping several repository writes. Plus the one design choice that decides whether writes compose.
Your win: read a command handler in your own service and explain how it runs multiple writes atomically — and spot which of two transaction styles it uses.
The shape of a command handler
A command handler is a struct that holds its dependencies as fields and exposes one write operation. Here's spike's, verbatim in structure:
type CreateEmailHandler struct {
DB database.Ext // the DB handle
EmailMetrics metrics.EmailMetrics
EmailRepo infrastructure.EmailRepo // a PORT (interface), not a concrete repo
EmailRecipientRepo infrastructure.EmailRecipientRepo // another PORT
}
Two things to notice. It depends on ports (the repo interfaces from Lesson 2), never concrete adapters — so it mocks cleanly in a unit test. And it holds only what this one operation needs. One operation, one handler, one file — that's the CQRS discipline from Lesson 5 made concrete.
The body: several writes, one transaction
Creating an email means writing two tables — the email and its recipients — and they must both succeed or both fail. The handler wraps them in a transaction:
func (cmd *CreateEmailHandler) CreateEmail(ctx, payload *CreateEmailPayload) (*dto.Email, error) {
payload.Email.EmailID = idutil.ULIDNow() // domain work first
emailEnt, _ := mapper.ToEmailEntity(payload.Email) // dto → model (Lesson 3)
recipientEnts, _ := mapper.ToEmailRecipientEntities(payload.Email)
err = database.ExecInTx(ctx, cmd.DB, func(ctx, tx pgx.Tx) error {
if err := cmd.EmailRepo.UpsertEmail(ctx, tx, emailEnt); err != nil { return err }
return cmd.EmailRecipientRepo.BulkUpsertEmailRecipients(ctx, tx, recipientEnts)
}) // both commit, or both roll back
}
database.Ext and not a pool
Look at what gets passed to the repos inside the transaction: tx, not
cmd.DB. That works because every port method takes database.Ext — the
interface that accepts both a pool and a pgx.Tx (Lesson 2's purity rule
paying off). The handler opens one transaction and threads the same tx through both
repository calls, so two writes to two tables land atomically — without any port ever mentioning
the word "transaction."1
Two ways to own the transaction
Now the nuance worth knowing, because your two services chose differently. Where does the transaction get opened?
| Style | Handler signature | Who owns the tx |
|---|---|---|
| Self-owned (spike) | holds DB; calls ExecInTx itself | the handler — self-contained, one write op = one tx |
| Caller-owned (notification) | takes db database.QueryExecer per call | the caller — so one tx can span several handlers |
func (cmd *SystemNotificationCommandHandler) UpsertSystemNotification(
ctx, db database.QueryExecer, payload ...) error { // db is a PARAMETER, not a field
...
return cmd.SystemNotificationRepo.UpsertSystemNotification(ctx, db, entity)
}
notification's canonical handler doesn't hold a DB and doesn't open a transaction —
it receives db on every call. That's deliberate: the module's own docs say
"db passed PER CALL (enables tx across handlers)."2
The caller can open one transaction and pass that tx into several command handlers, so
a whole multi-handler operation commits atomically. spike's self-owned style is simpler; the
caller-owned style composes. Both are correct CQRS command handlers — just different transaction
boundaries.
DB as a struct field → the handler owns its transaction (spike).
db as a method parameter → the caller owns it, and the handler is
composable into a bigger transaction (notification, the canonical shape). Reading which one a
handler uses tells you immediately how far a transaction can stretch.
Basic CQRS in Go — the command side
Three Dots Labs on command handlers as first-class types holding their dependencies — the exact shape your handlers use.
→ threedots.tech — basic CQRS in Go
→ in-repo internal/spike/modules/email/application/commands/create_email_handler.go,
.../system_notification/application/commands/upsert_system_notification_command_handler.go
Check yourself (from memory)
Q1. How does CreateEmail write two tables atomically?
ExecInTx; the same tx is passed as
database.Ext into both repo calls — both commit or both roll back.
Q2. notification's handler takes db as a parameter, not a field. Why?
db per call lets one
transaction, opened by the caller, wrap several command handlers. "db per call → tx across handlers."
Q3. What type does a command handler hold its repositories as?
database.ExecInTx(ctx, DB, func(ctx, tx){ repoA.Write(tx);
repoB.Write(tx) }) — same tx threaded through both because ports take
database.Ext (pool OR tx). Two tx styles: (1) self-owned
(spike CreateEmailHandler: DB is a field, opens its own ExecInTx —
simple, one op = one tx); (2) caller-owned (notification canonical: db is a
per-call parameter — caller opens the tx, so it can span several handlers → composable).
Tell them apart by DB-as-field vs DB-as-parameter.1. In-repo: internal/spike/modules/email/application/commands/create_email_handler.go:19-55.
2. In-repo: .../system_notification/application/commands/upsert_system_notification_command_handler.go:14-42; internal/notification/CLAUDE.md ("db passed PER CALL (enables tx across handlers)").