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:

internal/eureka/v2/modules/learning_time/repository/postgres/db/learning_time.sql.go:12
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:

.../db/querier.go:11
//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
Why an interface, not just a struct 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:

internal/eureka/v2/modules/learning_time/repository/postgres/learning_time_postgres.go:22
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

A bare &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.

RepoHolds a querier?Construction
eureka v2 (sqlc)yesmust use NewXRepo() — literal panics
spike / conversationmgmtno — stateless struct{}bare &Repo{} is fine

The honest caveat: adoption is partial

learning_time barely uses its own querier Don't over-generalise from this example: in 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
Read this next

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{}?

The repo holds a 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{}?

spike/conversationmgmt repos are stateless struct{} — no querier, nothing to fill — so a literal is fine. The rule is specific to sqlc repos.
Recall: the generated Querier + how the adapter wraps it + the gotcha.
generated files + injection + constructor rule + hybrid, then reveal
Generated: a typed 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.
🎯 Interview one-liner "How do you use the code sqlc generates?" → "It emits a Querier interface and a struct. A repository adapter holds the interface and delegates to it, passing our generic DB type through as the querier's DB handle — so a transaction flows down to the generated SQL. Because the repo holds that querier, we always build it via its constructor; a bare literal would leave the querier nil and panic."
Next: the payoff — assessment end-to-end, where hexagonal, CQRS, and sqlc finally click together in one flow. Ask me if the constructor-vs-literal distinction needs another pass — it's a favourite gotcha.

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.