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

Queries & the read/write split

Query handlers are the easy half — until you meet the rule that reads and writes are allowed to reach the database by completely different routes. On purpose.

Your win: read a query handler, then explain eureka's deliberate rule — read via the querier directly, write only through a repository — and why that asymmetry is safe.

The query handler: a read with no side effects

A query handler mirrors a command handler's shape but does less: it reads, shapes, and returns — no transaction, no state change. spike's:

internal/spike/modules/email/application/queries/get_email_event_handler.go:18
type GetEmailEventHandler struct {
    DB database.Ext
    EmailRecipientEventRepo infrastructure.EmailRecipientEventRepo   // a read PORT
}

func (h *GetEmailEventHandler) GetEmailDeliveryStatuses(ctx, payload ...) ([]*EmailDeliveryStatus, error) {
    events, _ := h.EmailRecipientEventRepo.GetEmailRecipientEventsByEmailIDs(ctx, h.DB, payload.EmailIDs)
    // pure logic: pick the highest-priority event per email, map to a status
    return results, nil
}

No ExecInTx — reads don't need one. It calls a repository and does its work in memory (here, resolving the "effective" delivery event per email). In spike and notification — which have no sqlc — a read goes through a repository interface, exactly like a write does. Reads and writes take the same route to the data.1

Then eureka breaks the symmetry — deliberately

eureka has sqlc, and with it comes a rule that looks inconsistent until you see the reasoning. Straight from eureka's conventions:2

OperationRule
ReadAny module may read any table. Use the sqlc Querier directly from the usecase.
WriteAll writes must go through a repository interface. "Never call sqlc mutating queries directly from a usecase."

You can see both halves in one function — the read hits the querier, the writes go through ports, all in one usecase:

internal/eureka/v2/modules/assessment/usecase/reset_student_study_plan.go:25
// READ — straight from the generated querier, no repo interface in the way
rows, _ := uc.AssessmentQuerier.ListRichSubmissionsByCourseAndStudents(ctx, uc.DB, params)

// WRITE — every mutation goes through a repository PORT, inside a transaction
database.ExecInTxV2(ctx, uc.DB, func(ctx, tx pgx.Tx) error {
    uc.SubmissionRemoverRepo.BulkRemoveSubmissions(ctx, tx, submissionIDs)       // :61
    uc.LearningTimeWriterRepo.BulkRemoveLearningTimesBySessionIDs(ctx, tx, sessionIDs) // :73
    // ... more repo writes
})
Why the asymmetry is safe — and smart A read has no invariants to protect: pulling rows can't corrupt anything, so letting a usecase hit the generated querier directly is a shortcut with no downside — no boilerplate repo method for every read. A write is different: every mutation must pass the repository so that domain invariants, event emission, and observability hooks are never bypassed.2 If a usecase could call querier.InsertX directly, it could sidestep the one layer that guarantees those things. So: reads are cheap and direct; writes are funnelled through a single guarded door. The asymmetry isn't sloppiness — it's the rule that makes the shortcut safe.
Connecting it back to your services Your services don't have this asymmetry — because they don't have sqlc, there's no "querier" to read from directly, so reads and writes both go through repos. The read/write split is a rule you only need once a typed query layer exists (Part 3). But knowing it now means that when you read eureka code and see a usecase touch a querier for a read but a repo for a write, you won't mistake it for inconsistency — you'll recognise the rule.
Read this next

Eureka's data-access rules

The in-repo rule table is the primary source here — read the "Bounded Context Data Access Rules" section and the "Why" beneath it.

→ in-repo internal/eureka/CLAUDE.md ("Bounded Context Data Access Rules") · .../assessment/usecase/reset_student_study_plan.go:25,60-77
Fowler — CQRS (the read/write model split)

Check yourself (from memory)

Q1. How does a query handler differ from a command handler?

Queries read, shape, and return — no side effects, no ExecInTx. Same struct shape as a command handler, less machinery.

Q2. In eureka, how may a usecase perform a write?

"Never call sqlc mutating queries directly from a usecase." Writes go through a repo port so invariants, events, and observability aren't bypassed.

Q3. Why is reading via the querier directly considered safe?

A read can't corrupt state or skip an event, so the repo layer buys nothing on the read path. Writes are where the guarantees matter.
Recall: the query handler + the read/write asymmetry.
query shape + eureka's rule + why safe, then reveal
Query handler: same struct shape as a command handler but read-only — calls a repo, shapes in memory, returns; no transaction (spike GetEmailEventHandler). In your services (no sqlc) reads & writes both go through repos. eureka's asymmetry (the rule): Read = hit the sqlc Querier directly from the usecase, any table. Write = only through a repository interface — "never call sqlc mutating queries from a usecase." Live in reset_student_study_plan.go: read at :25 via AssessmentQuerier, writes at :61-77 via repo ports in ExecInTxV2. Why safe: reads protect no invariants (bypass is harmless); writes must pass the repo so invariants + events + observability are never skipped. Asymmetry is what makes the read shortcut safe.
🎯 Interview one-liner "Do reads and writes go through the same layer?" → "Not in our sqlc service. Reads hit the generated query interface directly from the usecase — a read can't break an invariant, so the repository buys nothing there. But every write goes through a repository, so domain rules, event emission and observability are never bypassed. It's a deliberate asymmetry, and it's what makes the read shortcut safe."
Last in Part 2: wiring without a bus — how a controller finds its handler, where the composition root lives, and how it all connects back to Lesson 4's leak. Ask me if the "read direct, write via port" rule needs another pass — it returns in Part 3.

1. In-repo: internal/spike/modules/email/application/queries/get_email_event_handler.go:18-41.

2. In-repo (verified): internal/eureka/CLAUDE.md ("Bounded Context Data Access Rules" + "Why"); internal/eureka/v2/modules/assessment/usecase/reset_student_study_plan.go:25 (read), :60-77 (writes via repo ports).