The repository pattern¶
Spreading SQL through your handlers couples the whole program to one database. The fix is a boundary: an interface describing what you need, and one package that implements it.
type UserStore interface {
ByID(ctx context.Context, id int64) (User, error)
Create(ctx context.Context, name string) (User, error)
}
Everything above that boundary works in terms of User and
ErrUserNotFound. Nothing above it imports database/sql.
Two packages, one contract¶
The interface, the domain types and the sentinel errors go in a contract package. It has no database imports at all:
type User struct {
ID int64
Name string
}
var ErrUserNotFound = errors.New("user not found")
type UserStore interface {
ByID(ctx context.Context, id int64) (User, error)
Create(ctx context.Context, name string) (User, error)
}
The SQL lives in a separate implementation package, in an unexported struct holding the pool:
type userStore struct{ db *sql.DB }
func (s userStore) ByID(ctx context.Context, id int64) (User, error) {
var u User
err := s.db.QueryRowContext(ctx,
`SELECT id, name FROM users WHERE id=$1`, id).Scan(&u.ID, &u.Name)
if errors.Is(err, sql.ErrNoRows) {
return User{}, fmt.Errorf("id %d: %w", id, ErrUserNotFound)
}
if err != nil {
return User{}, fmt.Errorf("selecting user %d: %w", id, err)
}
return u, nil
}
Unexported, because callers should hold the interface. A constructor returns it:
Prove the implementation satisfies the contract at compile time:
Translate storage errors at the boundary¶
This is the part that carries the pattern. sql.ErrNoRows is a
database/sql concept; leaking it upward means every caller imports
the SQL package and a change of storage engine breaks all of them.
The store converts it:
_, err := store.ByID(ctx, 99999)
fmt.Println(errors.Is(err, ErrUserNotFound)) // output: true
fmt.Println(errors.Is(err, sql.ErrNoRows)) // output: false
fmt.Println(err) // output: id 99999: user not found
Wrapping with %w keeps errors.Is working while adding which id was
missing. Unexpected errors get wrapped too, but pass through as
themselves — the caller cannot mistake a dropped connection for a
missing row.
Now business logic reads in domain terms:
func (g Greeter) Greet(ctx context.Context, id int64) (string, error) {
u, err := g.users.ByID(ctx, id)
if errors.Is(err, ErrUserNotFound) {
return "hello, stranger", nil
}
if err != nil {
return "", fmt.Errorf("greeting %d: %w", id, err)
}
return "hello, " + u.Name, nil
}
Depend on the narrowest interface¶
A single Store interface with forty methods forces every consumer to
know about all of them, and every test double to implement all of them.
Declare the small set each consumer actually uses:
Greeter needs two methods. Give it two. This is "accept interfaces,
return concrete types" from
interfaces, applied to
storage.
A container struct is a reasonable way to assemble them at startup — just do not pass the whole container into every component:
type Stores struct {
Users UserStore
Posts PostStore
}
g := Greeter{users: stores.Users} // not: Greeter{stores}
Testing without a database¶
Because the consumer holds an interface, a hand-written double is enough. Function fields make each test say exactly what it needs:
type stubUsers struct {
ByIDFn func(ctx context.Context, id int64) (User, error)
}
func (s stubUsers) ByID(ctx context.Context, id int64) (User, error) {
return s.ByIDFn(ctx, id)
}
func (s stubUsers) Create(context.Context, string) (User, error) { return User{}, nil }
The same logic now runs with no database at all:
g := Greeter{users: stubUsers{ByIDFn: func(_ context.Context, id int64) (User, error) {
return User{ID: id, Name: "Stub"}, nil
}}}
fmt.Println(g.Greet(ctx, 1)) // output: hello, Stub <nil>
And the branches that are awkward to produce against a real database become one line each:
// not found
return User{}, ErrUserNotFound
// → hello, stranger <nil>
// connection failure
return User{}, errors.New("connection reset")
// → greeting 7: connection reset
That second case is the argument for the whole pattern. Simulating a dropped connection against a live database is hard; against an interface it is trivial.
Where it stops being worth it¶
- Do not add a method per query. A store with
ByName,ByNameAndStatus,ByNameAndStatusAndCreatedAfteris a query builder written badly. Take a filter struct instead. - Do not map everything. A reporting query feeding one endpoint can be a single method returning a purpose-built result type. Forcing it through the domain model helps nobody.
- Do not abstract for a swap that will not happen. The real benefits are testability and keeping SQL in one place. "We might change database" almost never materialises, and designing for it makes the interface worse.
- Transactions cross stores. Two stores in one transaction need to
share it — which is the context-carried transaction from the
previous article, precisely because the store
method signatures do not mention
*sql.Tx.
From Python: this is the repository pattern you would build over SQLAlchemy, but the interface is defined by the consumer and satisfied implicitly, so there is no base class and no registration. A test double is any struct with the right methods.
Quick reference¶
| Concern | Do |
|---|---|
| where the interface lives | the contract package, with the domain types |
| where the SQL lives | a separate package, unexported struct |
| constructor | func NewUserStore(db *sql.DB) UserStore |
| prove it satisfies | var _ UserStore = userStore{} |
| no rows | translate to your own ErrNotFound, wrapped with %w |
| other errors | wrap with context, pass through |
| what consumers take | the narrowest interface they use |
| test doubles | a struct with function fields |
| transactions across stores | carry it in the context |
| granularity | filter structs, not a method per query |