Day 096 — Repository Pattern¶
Month 4 · Week 2 · ⬅ Day 095 · Day 097 ➡ · Journal index
🎯 Learning Objective¶
Decouple business logic from persistence with the repository pattern: a consumer-side interface, a concrete database adapter, and domain errors — so the same service runs against Postgres in prod and a fake in tests.
📚 Topics¶
- Repository as a port; "accept interfaces, return structs"
- Domain (sentinel) errors vs leaking driver errors
- Testability with fakes; where the pattern leaks (cross-repo transactions)
📖 Reading / Sources¶
- Effective Go — Interfaces
- Go blog — Errors are values
- Martin Fowler — Repository
- Standard Go Project Layout — discussion on package boundaries
📝 Notes¶
- A repository is an abstraction over persistence: business code says "get user 7" and "save this order" without knowing it's SQL underneath. The win is decoupling — swap the storage engine without touching domain logic → [[repository-pattern]]. Runnable in
examples/month-04/repository. - Define the interface on the consumer side, not in the database package. Go's idiom is "accept interfaces, return structs": the service that needs persistence declares the small
UserRepointerface it depends on; the database package returns a concrete*PostgresRepothat happens to satisfy it. This keeps the interface minimal and the dependency arrow pointing the right way → [[accept-interfaces]]. - Return domain errors, not driver errors. The repo translates
pgx.ErrNoRowsinto a package-levelErrNotFound, and a unique-violation intoErrConflict. Callers match witherrors.Isand never see persistence details → [[sentinel-errors]] · [[error-wrapping]]. - Methods take
context.Contextfirst so queries are cancellable and carry deadlines/tracing → [[context-propagation]]. - Return copies, not pointers into the store. Handing out a
*Userthat aliases your map lets callers mutate "the database" by accident — the same [[slice-aliasing]]/shared-state trap from earlier weeks. - Testability is the payoff: the service depends only on the interface, so tests inject an in-memory fake — no Docker, no Postgres, milliseconds per test. That's exactly
exercises/month-04/week-2/inmemrepo. - Where it leaks: a single business operation spanning two repositories must share one transaction. A naive
UserRepo+OrderRepoeach owning its own*sql.DBconnection can't. The fix is a Unit of Work — pass aTx/Querierinto the repos (tomorrow's topic). Don't over-abstract: a thin interface aroundsqlc's generatedQueriesis often enough. - Keep the interface role-sized: prefer several small interfaces (
UserGetter,UserSaver) a caller actually uses over one fatRepositoryof 20 methods.
💻 Code Examples¶
// Interface lives with the CONSUMER (service), so the concrete repo is swappable.
type UserRepo interface {
Create(ctx context.Context, email string) (User, error)
Get(ctx context.Context, id int) (User, error)
}
// Domain errors — callers match these, never pgx/sql errors.
var (
ErrNotFound = errors.New("user: not found")
ErrConflict = errors.New("user: email already exists")
)
// A Postgres adapter translates driver errors into domain errors.
type PostgresRepo struct{ q *db.Queries } // db.Queries is sqlc-generated
func (r *PostgresRepo) Get(ctx context.Context, id int) (User, error) {
row, err := r.q.GetUser(ctx, int64(id))
if errors.Is(err, pgx.ErrNoRows) {
return User{}, fmt.Errorf("get %d: %w", id, ErrNotFound) // wrap the domain error
}
if err != nil {
return User{}, err
}
return User{ID: int(row.ID), Email: row.Email}, nil
}
Full runnable (interface + in-memory adapter + service):
examples/month-04/repository· Run:go run ./examples/month-04/repository
🏋️ Exercises / Practice¶
| Exercise | Status | Link |
|---|---|---|
In-memory User repository with ErrNotFound/ErrConflict, context-first |
✅ | exercises/month-04/week-2/inmemrepo |
| Repository pattern demo (interface + adapter + service) | ✅ | examples/month-04/repository |
🐛 Mistakes Made¶
- Defined the
UserRepointerface in the database package and imported it from the service — backwards. Moved it to the consumer so the database package only returns a concrete type. - Leaked
pgx.ErrNoRowsstraight to HTTP handlers; they coupled to the driver. Translated toErrNotFoundat the repo boundary. - Returned a
*Userpointing into my map and a caller mutated it. Switched to returning value copies.
❓ Open Questions¶
- How thin is too thin? When does wrapping sqlc's
Queriesin a hand-written repo add value over usingQueriesdirectly?
🧠 Active Recall (answer without looking)¶
- Q: On which side — database package or consumer — should the repository interface be defined, and why?
A
The consumer (service) side. Go's "accept interfaces, return structs" keeps the interface minimal and lets the database package return a concrete type the service depends on only abstractly.- Q: Why translate
pgx.ErrNoRowsinto a packageErrNotFoundat the repo boundary?
A
So callers match a stable domain error with `errors.Is` and never couple to the driver — you can swap pgx for another store without breaking call sites.🪶 Feynman Reflection¶
A repository is a counter clerk: the business hands it a request ("user 7, please") and gets back a domain object or a domain error, never seeing the filing system behind the counter. Because the clerk is defined by an interface, you can put a real database or a cardboard box behind the counter and the business never notices — which is exactly what makes tests fast.
🕳️ Knowledge Gaps¶
- Sharing one transaction across multiple repositories — resolved tomorrow with the Unit of Work /
Querierinjection.
✅ Summary¶
I can define a consumer-side repository interface, implement a concrete adapter that returns domain errors, keep context first, return copies, and test the service with an in-memory fake.
⏭️ Next Steps / Prep for Tomorrow¶
- Day 097: transactions,
BeginTx, the deferred-Rollback pattern, and context.
| Time spent | Difficulty | Confidence |
|---|---|---|
| 90 min | 🟦🟦⬜⬜⬜ | 🟦🟦🟦🟦⬜ |
Suggested commit: docs(journal): repository pattern (day 096)