Day 108 — Project: Repository & Migrations¶
Month 4 · Week 4 · ⬅ Day 107 · Day 109 ➡ · Journal index
🎯 Learning Objective¶
Implement the persistence layer of the capstone: a SQL-backed repository that satisfies the service's Repo interface, plus versioned, forward-only migrations to create the schema.
📚 Topics¶
database/sqlwithQueryRowContext/ExecContextandcontext- Mapping driver errors to domain errors (
sql.ErrNoRows → ErrNotFound) - Versioned migrations (
golang-migrate/goose) vs hand-rolled runner - Unique constraints,
RETURNING id, and conflict handling
📖 Reading / Sources¶
-
database/sqlpackage docs - Go wiki —
database/sqlbest practices (avoiding common pitfalls) - golang-migrate · pressly/goose
- jackc/pgx — the recommended Postgres driver
📝 Notes¶
- The repository is the only package that imports a driver. It implements the service's
Repointerface, so business logic never sees SQL → [[dependency-inversion]]. - Always use the
...Contextmethods (QueryRowContext,ExecContext,BeginTx) and pass the request'sctx, so a cancelled HTTP request also cancels the query → [[context-first-param]]. - Translate at the boundary:
if errors.Is(err, sql.ErrNoRows) { return ErrNotFound }. A Postgres unique-violation (23505) becomesErrConflict. Callers stay driver-agnostic → [[error-wrapping]]. - Use
RETURNING id(Postgres) to get the generated key in one round trip instead of a secondSELECT. - Migrations are forward-only and versioned: numbered
NNNN_name.up.sql/.down.sqlfiles applied in order, recorded in aschema_migrationstable so each runs once. Never edit an applied migration — add a new one → [[migrations]]. *sql.DBis a pool, not a single connection; it's safe for concurrent use and you keep one for the app's lifetime. ConfigureSetMaxOpenConns/SetConnMaxLifetime(Day 093) → [[connection-pool]].- Every
Rowsmust beClose()d (defer it) and you must checkrows.Err()after the loop, or you leak connections and miss late errors. sqlcgenerates type-safe Go from SQL;golang-migrateruns the schema. They are third-party tools — the concepts are modelled stdlib-only inexamples/month-04/repositoryandexamples/month-04/migrate.
💻 Code Examples¶
// Postgres adapter implementing the service's Repo interface (needs pgx/stdlib).
func (r *PostgresRepo) Get(ctx context.Context, id int) (User, error) {
const q = `SELECT id, email FROM users WHERE id = $1`
var u User
err := r.db.QueryRowContext(ctx, q, id).Scan(&u.ID, &u.Email)
switch {
case errors.Is(err, sql.ErrNoRows):
return User{}, fmt.Errorf("get %d: %w", id, ErrNotFound) // domain error, not driver error
case err != nil:
return User{}, fmt.Errorf("get %d: %w", id, err)
}
return u, nil
}
// 0001_users.up.sql — a forward migration, applied once and recorded.
// CREATE TABLE users (
// id BIGSERIAL PRIMARY KEY,
// email TEXT NOT NULL UNIQUE
// );
Stdlib analogues you can run:
examples/month-04/repositoryandexamples/month-04/migrate.
🏋️ Exercises / Practice¶
| Exercise | Status | Link |
|---|---|---|
| Service over the Repo interface (fake adapter) | ✅ | exercises/month-04/week-4/service |
| (Concept) repository + migrate runner | ✅ | examples/month-04/repository · examples/month-04/migrate |
🐛 Mistakes Made¶
- Returned
sql.ErrNoRowsstraight from the repo; the handler then couldn't map it cleanly. Wrapped it asErrNotFoundat the boundary. - Forgot
defer rows.Close()in aListquery and leaked pooled connections under load. Added the defer and arows.Err()check.
❓ Open Questions¶
- Embed migrations with
//go:embedand run them on startup, or run them as a separate CI step? (Leaning embed + explicitmigrate upcommand, not auto-run on boot.)
🧠 Active Recall (answer without looking)¶
- Q: Why must the repository translate
sql.ErrNoRowsbefore returning it?
A
So callers depend on a stable domain error (`ErrNotFound`) instead of a driver detail. Swapping Postgres for SQLite then doesn't ripple into the service or handler.- Q: What makes a migration "forward-only and versioned," and why never edit an applied one?
A
Each migration has a unique increasing version recorded in `schema_migrations` and runs exactly once. Editing an applied migration would diverge environments that already ran the old version — add a new migration instead.🪶 Feynman Reflection¶
The repository is a power adapter: the service speaks one plug shape (the Repo interface and domain errors), and the adapter converts that to whatever the wall socket (Postgres, SQLite, a map) provides. Migrations are the building's wiring history — an append-only log of changes so any site can be brought up to the current schema by replaying the steps it hasn't seen.
🕳️ Knowledge Gaps¶
- pgx native interface vs
database/sqlcompatibility layer — performance/feature trade-offs. Revisit before choosing in production.
✅ Summary¶
I implemented a context-aware SQL repository that hides the driver behind domain errors, and set up forward-only versioned migrations for the schema.
⏭️ Next Steps / Prep for Tomorrow¶
- Day 109: integration tests that boot the whole app against a real database with testcontainers.
| Time spent | Difficulty | Confidence |
|---|---|---|
| 90 min | 🟦🟦⬜⬜⬜ | 🟦🟦🟦⬜⬜ |
Suggested commit: feat(project): SQL repository and migrations (day 108)