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:

internal/spike/modules/email/application/commands/create_email_handler.go:19
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:

create_email_handler.go:27 (condensed)
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
}
Why the ports take 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?

StyleHandler signatureWho owns the tx
Self-owned (spike)holds DB; calls ExecInTx itselfthe handler — self-contained, one write op = one tx
Caller-owned (notification)takes db database.QueryExecer per callthe caller — so one tx can span several handlers
internal/notification/modules/system_notification/application/commands/upsert_system_notification_command_handler.go:18
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.

The tell 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.
Read this next

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?

One 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?

Caller-owned tx: passing 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?

Handlers depend on repo interfaces (ports) — never concrete adapters — so a unit test injects mocks. Lesson 2's inversion at work.
Recall: the command handler shape + the two transaction styles.
shape + atomic writes + who owns the tx, then reveal
Shape: a struct holding deps as fields — repo ports (interfaces, not concrete) + metrics — with one write method. One op / one handler / one file. Atomic writes: 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.
🎯 Interview one-liner "How do you keep multiple writes consistent?" → "A command handler opens one transaction and threads the same tx handle through each repository call — the repo interfaces take a generic DB type that's either a pool or a tx, so nothing below knows about transactions. Where handlers need to compose, we pass the DB per call so the caller owns the transaction boundary across several handlers."
Next: the read side — query handlers — and the course's second flagship, the deliberate rule that reads and writes take different paths to the data. Ask me if the two transaction styles are worth re-walking.

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)").