Lesson 11 · sqlc — typed SQL + the synthesis
sqlc in the hexagon
The payoff: one real function where hexagonal, CQRS, and sqlc all click together — a read that skips the port and writes that don't, both ending at the same generated SQL.
Your win: trace one usecase end to end and name every pattern as it appears — ports, the read/write asymmetry, the querier, cross-module writes — in a single flow.
The usecase holds both a querier and repo ports
Look at what eureka's assessment usecase depends on. It's the whole course in one struct:
type AssessmentUsecaseImpl struct {
DB database.Ext
SubmissionRemoverRepo repository.SubmissionRemoverRepo // write PORT (this module)
LearningTimeWriterRepo learning_time_repository.LearningTimeWriterRepo // write PORT (another module!)
LearningSubmissionRemoverRepo book_repository.LearningSubmissionRemoverRepo // write PORT (another module!)
...
AssessmentQuerier assessment_db.Querier // sqlc querier — for direct READS
}
Two kinds of dependency, side by side: repo ports for writes (Lesson 2), and the
sqlc Querier for reads (Lesson 10). That single struct encodes the
read/write asymmetry from Lesson 7 as types.1
The flow: read direct, write through the port
func (uc *AssessmentUsecaseImpl) ResetStudentStudyPlan(ctx, courseID string, studentIDs []string) error {
// ── READ: straight from the querier, no port ──
rows, _ := uc.AssessmentQuerier.ListRichSubmissionsByCourseAndStudents(ctx, uc.DB, params) // :25
// ── WRITE: every mutation through a repo PORT, all in one 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 (cross-module)
uc.LearningSubmissionRemoverRepo.BulkRemove…(ctx, tx, ...) // :77 (cross-module)
return nil
})
}
The read calls the querier directly. The writes each go through a repository port, and the same
tx threads through all of them (Lesson 6) so the whole reset is atomic. Notice two of
the write ports belong to other modules — learning_time and book —
injected as interfaces. eureka's rule: to write another module's table, inject its repo
port; never reach for its querier.2
Follow one write all the way down
What happens behind SubmissionRemoverRepo.BulkRemoveSubmissions? The adapter that
implements that port holds its own querier and delegates:
type SubmissionRepo struct { querier assessment_db.Querier } // the adapter
func NewSubmissionRepo() *SubmissionRepo { return &SubmissionRepo{querier: assessment_db.New()} }
// BulkRemoveSubmissions → s.querier.BulkRemoveSubmissions(ctx, ext, ...Params{...}) (~:1198)
SubmissionRepo
is the adapter. CQRS / read-write split — the read hits the querier
directly; every write goes through a port, in a transaction. sqlc — both paths
ultimately call generated, type-safe query methods. The read shortcut is safe precisely because
writes are funnelled through the port that carries invariants, events, and observability (Lesson 7).
Three patterns, one function.
ExecInTx. The only difference is that eureka's adapter
delegates to a generated querier and its usecase may read straight from one. Same
hexagon, same command/query split — sqlc just changes what sits inside the adapter and adds one
sanctioned read shortcut.
DDD, CQRS & Clean Architecture, combined
Three Dots Labs assembling exactly this — ports, command/query handlers, and a real repository —
into one coherent flow. Read it with ResetStudentStudyPlan in mind.
→ threedots.tech — combined
→ in-repo .../assessment/usecase/reset_student_study_plan.go,
.../assessment/usecase/abstraction.go:89-140,
.../assessment/repository/postgres/submission_postgres.go:23-29
Check yourself (from memory)
Q1. The assessment usecase holds two kinds of DB dependency. Which?
Querier
for direct reads — the read/write asymmetry encoded as struct fields.
Q2. To write another module's table, the usecase uses…
learning_time, book) — never its querier. Writes always pass a port.
Q3. Where do the read path and the write path end up?
AssessmentUsecaseImpl): holds write
repo ports (incl. cross-module learning_time/book) AND
the sqlc AssessmentQuerier for reads — the asymmetry as types. The flow
(ResetStudentStudyPlan): READ via uc.AssessmentQuerier.List… directly
(:25); WRITE via repo ports in ExecInTxV2 (:61-77), same tx → atomic.
Down a write: usecase → port (interface) → adapter SubmissionRepo
(holds its own querier, built by NewSubmissionRepo()) → querier → generated SQL → DB.
Both paths end at the same querier; only the write passes the guard port.
= the whole course: hexagonal (port/adapter) + CQRS (read/write split) + sqlc
(typed queries), one function.1. In-repo (verified): .../assessment/usecase/abstraction.go:89,117-121,140 (ports + AssessmentQuerier).
2. In-repo (verified): .../assessment/usecase/reset_student_study_plan.go:25 (read), :60-77 (cross-module writes via ports); .../assessment/repository/postgres/submission_postgres.go:23-29; internal/eureka/CLAUDE.md (cross-module write rule).