Skip to content

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

📝 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 UserRepo interface it depends on; the database package returns a concrete *PostgresRepo that 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.ErrNoRows into a package-level ErrNotFound, and a unique-violation into ErrConflict. Callers match with errors.Is and never see persistence details → [[sentinel-errors]] · [[error-wrapping]].
  • Methods take context.Context first so queries are cancellable and carry deadlines/tracing → [[context-propagation]].
  • Return copies, not pointers into the store. Handing out a *User that 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 + OrderRepo each owning its own *sql.DB connection can't. The fix is a Unit of Work — pass a Tx/Querier into the repos (tomorrow's topic). Don't over-abstract: a thin interface around sqlc's generated Queries is often enough.
  • Keep the interface role-sized: prefer several small interfaces (UserGetter, UserSaver) a caller actually uses over one fat Repository of 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 UserRepo interface 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.ErrNoRows straight to HTTP handlers; they coupled to the driver. Translated to ErrNotFound at the repo boundary.
  • Returned a *User pointing 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 Queries in a hand-written repo add value over using Queries directly?

🧠 Active Recall (answer without looking)

  1. 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.
  1. Q: Why translate pgx.ErrNoRows into a package ErrNotFound at 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 / Querier injection.

✅ 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)