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:
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
| Operation | Rule |
|---|---|
| Read | Any module may read any table. Use the sqlc Querier directly from the usecase. |
| Write | All 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:
// 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
})
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.
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?
ExecInTx. Same struct shape as a command handler, less machinery.
Q2. In eureka, how may a usecase perform a write?
Q3. Why is reading via the querier directly considered safe?
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.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).