Lesson 10 · sqlc — typed SQL + the synthesis
The Querier & the adapter
What that one .sql file becomes — a mockable interface and a struct — and how a repository adapter wraps it, including the one-line mistake that panics at startup.
Your win: read the generated Querier, explain how a repo wraps it,
and say why it must be built with a constructor — while your own repos need no such thing.
What sqlc generated from that query
The four-line learning_time.sql from Lesson 9 became a small set of files. Two matter
most. First, the method — note what sqlc did to the SQL:
type BulkRemoveLearningTimesBySessionIDsParams struct {
SessionIds []string // ← typed param struct, from @session_ids::text[]
}
func (q *Queries) BulkRemoveLearningTimesBySessionIDs(ctx, db DBTX, arg ...Params) error {
ctx, span := interceptors.StartSpan(ctx, "LearningTimeQueries.BulkRemove…") // ← auto span (emit_tracing)
defer span.End()
_, err := db.Exec(ctx, bulkRemove…, arg.SessionIds) // @session_ids became $1
return err
}
The named @session_ids is now a real []string field; the tracing span
you never wrote is already there; the SQL runs through db.Exec. Second, the interface —
this is the piece the rest of the code depends on:
//go:generate ... mockery --name=Querier ... ← the mock is generated too
type Querier interface {
BulkRemoveLearningTimesBySessionIDs(ctx, db DBTX, arg ...Params) error
}
var _ Querier = (*Queries)(nil) // compile-time proof Queries implements it
emit_interface makes sqlc emit a Querier interface plus the
go:generate line that mocks it. So a repository can depend on Querier
(the interface), and a unit test can swap in the generated mock — no database needed. It's
ports-and-adapters again, one level deeper: even the generated SQL layer is reached through an
interface.1
How the adapter wraps the Querier
Now the repository adapter — the concrete implementation of a port from Lesson 2 — holds a
Querier and delegates to it:
type LearningTimePostgresRepo struct {
querier lt_db.Querier // ← holds the generated interface
}
func NewLearningTimePostgresRepo() *LearningTimePostgresRepo {
return &LearningTimePostgresRepo{querier: lt_db.New()} // ← constructor fills it
}
func (l *LearningTimePostgresRepo) BulkRemoveLearningTimesBySessionIDs(ctx, ext database.Ext, ids []string) error {
return l.querier.BulkRemoveLearningTimesBySessionIDs(ctx, ext, lt_db.…Params{SessionIds: ids})
}
The port method takes database.Ext and passes it straight through as the querier's
DBTX — and that works because database.Ext satisfies the generated
DBTX interface (Exec/Query/QueryRow). So a transaction handle passed into
the port flows all the way down to the generated SQL. The Lesson 6 transaction, the Lesson 2 port,
and this generated code all connect through that one database.Ext.2
The gotcha: constructor, never a bare literal
&Repo{} here panics at startup
Because the repo holds a querier field that only the constructor fills,
building it as a bare literal &LearningTimePostgresRepo{} leaves
querier nil — the first call panics with a nil-pointer dereference. The convention is
absolute: always NewLearningTimePostgresRepo(), never
&LearningTimePostgresRepo{}.3
Here's the twist that ties back to Part 1. Your services do use bare literals —
conversationmgmt's leak (Lesson 4) news up &postgres.ConversationRepo{}, and
spike wires &repositories.EmailRepo{} — and that's completely fine, because those
repos are stateless struct{}: no querier field, nothing to
initialise. The constructor rule isn't about style; it's specifically about the sqlc
querier. Same repo concept, different construction requirement — decided entirely by
whether the repo holds a generated querier.
| Repo | Holds a querier? | Construction |
|---|---|---|
| eureka v2 (sqlc) | yes | must use NewXRepo() — literal panics |
| spike / conversationmgmt | no — stateless struct{} | bare &Repo{} is fine |
The honest caveat: adoption is partial
learning_time_postgres.go, exactly
one method (BulkRemoveLearningTimesBySessionIDs) actually calls the
querier. The other ~16 hand-roll SQL with database.Insert / UpdateFields /
fmt.Sprintf — the same dynamic style your services use. sqlc is a hybrid
adoption even inside eureka v2. The module that uses it most fully is assessment —
which is exactly why the next lesson's end-to-end example lives there.3
sqlc — the generated code & interfaces
The docs page on generated structs and the emit_interface option — match it against
the querier.go you just read.
→ docs.sqlc.dev — config & emit options
→ in-repo .../db/{querier.go, db.go, learning_time.sql.go},
.../learning_time_postgres.go:22-28,83-87,
.claude/rules/eureka-v2-conventions.md
Check yourself (from memory)
Q1. Why does sqlc emit a Querier interface?
emit_interface + the generated mock let a repo depend
on the interface and a unit test run with no database. Ports-and-adapters, one level deeper.
Q2. Why must an sqlc repo use NewXRepo(), not &Repo{}?
querier only the constructor
initialises (db.New()). A bare literal leaves it nil → panic on first use.
Q3. Why can spike safely use a bare &EmailRepo{}?
struct{} —
no querier, nothing to fill — so a literal is fine. The rule is specific to sqlc repos.
Params struct + a method (with
an auto emit_tracing span, @name→$1) + a Querier
interface (emit_interface) with a go:generate mock, and var _
Querier = (*Queries)(nil). Adapter: repo holds querier lt_db.Querier,
NewXRepo() fills it via db.New(), port methods take database.Ext
and pass it through as DBTX (satisfied by pool OR tx → tx flows to the SQL).
Gotcha: bare &Repo{} leaves querier nil → panic —
must use the constructor. Spike/convo repos are stateless struct{} →
literal fine. Hybrid: learning_time uses the querier in only 1 method;
assessment is the fullest sqlc user.1. In-repo (verified): .../db/querier.go:11-15, .../db/learning_time.sql.go:12-27.
2. In-repo (verified): .../db/db.go:12-23 (DBTX, New()), .../learning_time_postgres.go:83-87.
3. In-repo (verified): .../learning_time_postgres.go:22-28 (constructor); .claude/rules/eureka-v2-conventions.md ("never a bare struct literal … panics"); hybrid = only :83 uses the querier.