The Repository Pattern in Go and Whether It Is Worth It
What the repository pattern is in Go, what it buys and what it costs: a per-table version issued 201 queries for 200 orders, one use-case method issued one.

The repository pattern is one of those ideas that arrives in Go from other languages with its full ceremony and gets applied before anyone has asked what it is for. Sometimes it earns its keep. Often it becomes a folder of files that forward calls and make every query slower to write. This article states what the pattern is, shows with a measurement what goes wrong when it is applied by table, and gives a rule for deciding whether a service needs one. It is part of the databases cluster and links to small store interfaces, the lighter alternative.
Tested with Go 1.25.6, pgx v5.11.0, PostgreSQL 18.6 in Docker, macOS on Apple Silicon (arm64)
What a repository is
A repository is an object with methods that read and write your domain's data without revealing how it is stored. The caller says orders.ListRecent(ctx, 20) and does not know whether that is PostgreSQL, an in-memory map or an HTTP call. In Go it is usually an interface plus a struct that implements it against database/sql.
It promises two things:
- One place for storage knowledge. SQL, column names, driver errors and the mapping between rows and types live in one package, so a schema change touches one place.
- A seam for tests. Code that depends on the interface can run against a fake, so most tests need no database.
Both are real benefits. The pattern fails when it promises a third thing it cannot deliver, which is that the storage choice can be swapped freely. Real services depend on transactions, on query shapes and on what the database does well. An interface that hides all of that either leaks it back out through awkward methods or gives up performance.
What goes wrong when it follows the tables
The most common shape is one repository per table, with a method for each question you can ask about that table: OrderRepo.ListRecent, CustomerRepo.Get. It looks tidy. The lab below builds a page of recent orders with the customer's name, once the textbook way and once as a single method designed around the page.
package main
import (
"context"
"database/sql"
"fmt"
"log"
"sync/atomic"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
const dsn = "postgres://postgres:[email protected]:55432/lab?sslmode=disable"
// DB counts every statement that reaches the server.
type DB struct {
*sql.DB
queries atomic.Int64
}
func (d *DB) QueryRowContext(ctx context.Context, q string, args ...any) *sql.Row {
d.queries.Add(1)
return d.DB.QueryRowContext(ctx, q, args...)
}
func (d *DB) QueryContext(ctx context.Context, q string, args ...any) (*sql.Rows, error) {
d.queries.Add(1)
return d.DB.QueryContext(ctx, q, args...)
}
type Order struct {
ID int64
CustomerID int64
Total int
}
type Customer struct {
ID int64
Name string
}
// The textbook repositories: one per table, one method per question about that table.
type OrderRepo struct{ db *DB }
func (r OrderRepo) ListRecent(ctx context.Context, n int) ([]Order, error) {
rows, err := r.db.QueryContext(ctx, "select id, customer_id, total from orders606 order by id desc limit $1", n)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Order
for rows.Next() {
var o Order
if err := rows.Scan(&o.ID, &o.CustomerID, &o.Total); err != nil {
return nil, err
}
out = append(out, o)
}
return out, rows.Err()
}
type CustomerRepo struct{ db *DB }
func (r CustomerRepo) Get(ctx context.Context, id int64) (Customer, error) {
var c Customer
err := r.db.QueryRowContext(ctx, "select id, name from customers606 where id = $1", id).Scan(&c.ID, &c.Name)
return c, err
}
// The same page, asked as one question about the use case.
type OrderSummary struct {
OrderID int64
Customer string
Total int
}
type ReportRepo struct{ db *DB }
func (r ReportRepo) RecentWithCustomer(ctx context.Context, n int) ([]OrderSummary, error) {
rows, err := r.db.QueryContext(ctx, `select o.id, c.name, o.total
from orders606 o join customers606 c on c.id = o.customer_id
order by o.id desc limit $1`, n)
if err != nil {
return nil, err
}
defer rows.Close()
var out []OrderSummary
for rows.Next() {
var s OrderSummary
if err := rows.Scan(&s.OrderID, &s.Customer, &s.Total); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
func main() {
ctx := context.Background()
raw, err := sql.Open("pgx", dsn)
if err != nil {
log.Fatal(err)
}
defer raw.Close()
for _, q := range []string{
"drop table if exists orders606", "drop table if exists customers606",
"create table customers606 (id bigserial primary key, name text not null)",
"create table orders606 (id bigserial primary key, customer_id bigint not null references customers606, total int not null)",
"insert into customers606 (name) select 'customer ' || i from generate_series(1, 500) i",
"insert into orders606 (customer_id, total) select (i % 500) + 1, i from generate_series(1, 5000) i",
"analyze orders606",
} {
if _, err := raw.ExecContext(ctx, q); err != nil {
log.Fatal(err)
}
}
db := &DB{DB: raw}
for _, n := range []int{10, 50, 200} {
db.queries.Store(0)
start := time.Now()
orders, err := OrderRepo{db}.ListRecent(ctx, n)
if err != nil {
log.Fatal(err)
}
customers := CustomerRepo{db}
for _, o := range orders {
if _, err := customers.Get(ctx, o.CustomerID); err != nil {
log.Fatal(err)
}
}
perEntity := time.Since(start)
q1 := db.queries.Load()
db.queries.Store(0)
start = time.Now()
if _, err := (ReportRepo{db}).RecentWithCustomer(ctx, n); err != nil {
log.Fatal(err)
}
oneQuery := time.Since(start)
fmt.Printf("page of %3d orders: per-table repositories %3d queries in %-8v | one use-case method %d query in %v\n",
n, q1, perEntity.Round(10*time.Microsecond), db.queries.Load(), oneQuery.Round(10*time.Microsecond))
}
}page of 10 orders: per-table repositories 11 queries in 9.46ms | one use-case method 1 query in 1.1ms
page of 50 orders: per-table repositories 51 queries in 19.73ms | one use-case method 1 query in 610µs
page of 200 orders: per-table repositories 201 queries in 57.17ms | one use-case method 1 query in 760µsThe program wraps *sql.DB in a type that counts the statements reaching the server, loads 500 customers and 5,000 orders, and then builds three pages. The numbers are the whole argument:
- A page of 10 orders cost 11 queries, 50 cost 51 and 200 cost 201. The page asks
OrderRepofor orders, then asksCustomerRepo.Getfor each order's customer: one query plus one for every row, the classic N plus one. - The same page written as
ReportRepo.RecentWithCustomer, one method with a join, cost one query every time. For 200 orders it took 760µs where the per-table version took 57.17ms.
Nothing here is a flaw in the pattern as such. Each repository method is correct and each is tidy. The problem is the interface: it divides the world along table lines, so the only way to combine two tables is to call two repositories in a loop. A caller that wants a join has nowhere to put it. The loop looks innocent in a code review, because each call is a method with a clear name, and the cost is invisible until the page is slow. How to find these in a running service is the subject of finding N plus one queries.
On a local server the cost is milliseconds. Over a network, with 1ms of round trip to a database in the same region, 201 queries are 200ms of waiting before any work, on every request, per user. The measurement understates what production feels like.
Design the methods around questions
The fix is to stop treating a repository as a wrapper around a table and treat it as an answer to a question the application asks. Three rules follow:
- Name methods after use cases.
RecentWithCustomer,OverdueInvoices,CartForCheckout. A method name that describes the question can be backed by a join, a view or a cache without changing its callers. - Return the shape the caller needs. An
OrderSummarywith a customer name is a read model, not a domain entity, and that is fine. Forcing every read through the entity types is what produces the loop above. - Keep writes small and explicit. A write method takes what it needs and returns the new state. Accepting a generic filter or map pushes SQL building into callers, which is exactly the knowledge the pattern was meant to contain.
This also settles the argument about whether a repository should return entities or rows. Both, for different jobs: entities for the write side, where invariants matter, and purpose built read types for the pages that display data.
Where transactions fit
Repositories and transactions collide quickly. If each repository method opens its own connection, a business operation that touches two of them cannot be atomic. The standard answer is to let a method accept something that is either a *sql.DB or a *sql.Tx, through a small interface of the methods both share, and to hand a unit of work a set of repositories bound to one transaction. That design is worked through in unit of work and transaction boundaries. A repository that cannot take part in a caller's transaction will be bypassed the first time a feature needs atomicity, and then the layer is decoration.
When it is worth it
| Situation | Verdict |
|---|---|
| Several services or handlers share the same queries | Worth it: one package owns the SQL |
| Tests need to run without a database for speed | Worth it, or use a small consumer side interface |
| The storage might change (a cache in front, a second store) | Worth it, as long as methods are shaped by use cases |
| A small service, one store, a few queries | Probably not: call sqlc or database/sql directly from a thin store type |
| A CRUD service where every method is a table operation | Not worth a hand written layer: generate it, or use an ORM knowingly |
Go makes the middle path cheap. Interfaces are satisfied implicitly, so the consumer can declare the three methods it uses and any struct with those methods qualifies, without a central repository package at all. That is the approach in keeping SQL out of handlers with small store interfaces, and it delivers the testing benefit without the ceremony.
What to avoid
- A generic repository (
Repository[T]withGet,List,Save). It is the per-table design with the type parameter added. It cannot express a join, a lock or a partial update, so real queries leak around it. - Mirroring the ORM. A repository over an ORM that exposes the ORM's query builder is two layers where there is one.
- Hiding errors the caller needs. Return a typed
ErrNotFoundand a conflict error, as the next articles show, and let the rest propagate wrapped. - Mocking the repository for query logic. A test that stubs the repository checks the caller's reaction to a canned answer. It says nothing about whether the SQL is right, which needs a real database, as in testing against PostgreSQL.
The short version
Use a repository when it gives you a seam and a single home for SQL, design its methods around the questions the application asks, and make it take part in transactions. Do not build one repository per table with generic methods, because the lab above shows what that costs: 201 queries where one would do. The same trade-off, in Go specific terms, is discussed in the Go team's own guide to accessing relational databases, which calls out the limits of hiding the database, and in Martin Fowler's description of the pattern, where the original intent is stated.


