5. Repository layer
Now that domain exists, we need somewhere to actually put an order. This chapter builds that in
two pieces: an interface describing what the service layer needs from storage, written before
any storage technology is chosen, and a real Postgres implementation of it. By the end, you’ll have
a working, tested repository — and a real database running in Docker to prove it against.
Declare what you need before deciding how to store it
Create repository/repository.go:
package repository
import (
"context"
"uuid"
"example.com/servoorders/internal/domain"
)
type OrderRepository interface {
Create(ctx context.Context, o *domain.Order) error
Get(ctx context.Context, id uuid.UUID) (*domain.Order, error)
ListByUser(ctx context.Context, userID uuid.UUID, limit, offset int) ([]*domain.Order, error)
}
type UserRepository interface {
GetByUsername(ctx context.Context, username string) (*domain.User, error)
}
Writing the interface first, before postgres exists at all, is the same principle from
chapter 4 applied one layer
up: whoever consumes an interface should own it. postgres is about to import repository;
repository will never import postgres. Keep that direction in mind as you write the
implementation below — it’s what will let chapter 17 swap in a mock
without either side knowing it happened.
Build the Postgres implementation
Create repository/postgres/postgres.go. Start with the type and its constructor:
package postgres
import (
"context"
"errors"
"fmt"
"uuid"
"example.com/servoorders/internal/config"
"example.com/servoorders/internal/domain"
"example.com/servoorders/internal/repository/migrations"
"example.com/servoorders/internal/repository"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Store struct {
pool *pgxpool.Pool
}
var (
_ repository.OrderRepository = (*Store)(nil)
_ repository.UserRepository = (*Store)(nil)
)
const envPrefix = "POSTGRES_"
type Config struct {
DSN string `env:"DSN,required"`
}
func NewConfig(src config.Source) (*Config, error) {
return config.Parse[Config](src, envPrefix)
}
func New(cfg *Config) (*Store, error) {
pool, err := pgxpool.New(context.Background(), cfg.DSN)
if err != nil {
return nil, fmt.Errorf("postgres: %w", err)
}
return &Store{pool: pool}, nil
}
The two var _ repository.X = (*Store)(nil) lines aren’t runtime code — they’re compile-time
assertions. If Store ever stops satisfying either interface, the build fails right here, with a
clear message, instead of failing somewhere far away the first time something tries to use it as
one.
pgxpool.New doesn’t actually open a connection yet; it only validates the DSN and prepares a pool
lazily. Add the three methods that give this type real startup, shutdown, and health behavior:
func (s *Store) Init(ctx context.Context) error {
if err := s.pool.Ping(ctx); err != nil {
return fmt.Errorf("postgres: ping: %w", err)
}
return migrations.Run(ctx, s.pool)
}
func (s *Store) Stop(context.Context) error {
s.pool.Close()
return nil
}
func (s *Store) Health(ctx context.Context) error {
return s.pool.Ping(ctx)
}
This is the first point where the real connectivity check happens — a broken DSN or an unreachable
database now fails at startup, not on the first request that happens to touch it. Hold onto the
names Init, Stop, and Health for a moment: chapter 13 is where
we’ll see servo discover and call these automatically, purely because they exist on the type — not
because Store imports servo or registers itself anywhere. It doesn’t; check the imports above
again if you like. That’s worth sitting with now, before more components pick up the same pattern
and it starts to feel routine.
Now the actual queries. Get is the one to look at closely, because it has to make a decision
Create and the others don’t — what happens when the row simply isn’t there:
func (s *Store) Get(ctx context.Context, id uuid.UUID) (*domain.Order, error) {
var o domain.Order
err := s.pool.QueryRow(ctx,
`SELECT id, user_id, item, quantity, status, created_at FROM orders WHERE id = $1`, id,
).Scan(&o.ID, &o.UserID, &o.Item, &o.Quantity, &o.Status, &o.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("postgres: get order: %w", err)
}
return &o, nil
}
pgx.ErrNoRows is pgx’s own way of saying “the query succeeded, it just matched nothing.” Without
that errors.Is check, a perfectly normal “no such order” would come back as an opaque wrapped
error instead of the domain.ErrNotFound every layer above this one already knows how to handle —
and three layers up, in the API layer, that’s the difference between a clean 404
and a confusing 500. GetByUsername needs the exact same check, for the same reason; write it now
while the pattern is fresh:
func (s *Store) GetByUsername(ctx context.Context, username string) (*domain.User, error) {
var u domain.User
err := s.pool.QueryRow(ctx,
`SELECT id, username, password_hash FROM users WHERE username = $1`, username,
).Scan(&u.ID, &u.Username, &u.PasswordHash)
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("postgres: get user: %w", err)
}
return &u, nil
}
Create and ListByUser round out the interface — no new ideas in either, just the same
query/scan shape:
func (s *Store) Create(ctx context.Context, o *domain.Order) error {
_, err := s.pool.Exec(ctx,
`INSERT INTO orders (id, user_id, item, quantity, status, created_at) VALUES ($1, $2, $3, $4, $5, $6)`,
o.ID, o.UserID, o.Item, o.Quantity, o.Status, o.CreatedAt)
if err != nil {
return fmt.Errorf("postgres: create order: %w", err)
}
return nil
}
func (s *Store) ListByUser(ctx context.Context, userID uuid.UUID, limit, offset int) ([]*domain.Order, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, user_id, item, quantity, status, created_at FROM orders
WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`,
userID, limit, offset)
if err != nil {
return nil, fmt.Errorf("postgres: list orders: %w", err)
}
defer rows.Close()
var orders []*domain.Order
for rows.Next() {
var o domain.Order
if err := rows.Scan(&o.ID, &o.UserID, &o.Item, &o.Quantity, &o.Status, &o.CreatedAt); err != nil {
return nil, fmt.Errorf("postgres: scan order: %w", err)
}
orders = append(orders, &o)
}
return orders, rows.Err()
}
Notice ListByUser deliberately has no pgx.ErrNoRows check — a user with zero orders is a
perfectly normal, empty result, not an error. That distinction (one query where “nothing found”
means “fail,” another where it just means “empty”) is worth noticing now, because it’s easy to
copy-paste the wrong one once there are more query methods than these four.
Give the database something to migrate to
Store.Init calls migrations.Run, which doesn’t exist yet. Rather than reach for a full
migration library for four tables, we’ll write a small runner ourselves — about forty lines,
everything visible in one file, nothing to look up in someone else’s documentation to understand
what it does. (A tool like golang-migrate or
goose is genuinely the better choice once a team has more
than a handful of migrations, or needs to roll one back in production — see
chapter 21 for when to make that switch.)
Create repository/migrations/migrations.go:
package migrations
import (
"context"
"embed"
"fmt"
"io/fs"
"sort"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed *.sql
var files embed.FS
func Run(ctx context.Context, pool *pgxpool.Pool) error {
if _, err := pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
return fmt.Errorf("migrations: create tracking table: %w", err)
}
names, err := fs.Glob(files, "*.sql")
if err != nil {
return fmt.Errorf("migrations: glob: %w", err)
}
sort.Strings(names)
for _, name := range names {
if err := apply(ctx, pool, name); err != nil {
return err
}
}
return nil
}
//go:embed *.sql compiles every .sql file in this directory straight into the binary — no
separate migrations directory to deploy alongside it, no path to get wrong at runtime. apply is
the part that makes running this on every startup safe rather than alarming:
func apply(ctx context.Context, pool *pgxpool.Pool, name string) error {
var applied bool
err := pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)`, name,
).Scan(&applied)
if err != nil {
return fmt.Errorf("migrations: check %s: %w", name, err)
}
if applied {
return nil
}
body, err := files.ReadFile(name)
if err != nil {
return fmt.Errorf("migrations: read %s: %w", name, err)
}
tx, err := pool.Begin(ctx)
if err != nil {
return fmt.Errorf("migrations: begin %s: %w", name, err)
}
defer tx.Rollback(ctx) // no-op once Commit succeeds
if _, err := tx.Exec(ctx, string(body)); err != nil {
return fmt.Errorf("migrations: apply %s: %w", name, err)
}
if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, name); err != nil {
return fmt.Errorf("migrations: record %s: %w", name, err)
}
return tx.Commit(ctx)
}
Each file is checked against schema_migrations, skipped if it’s already there, and — this is the
part that matters — applied and recorded inside one transaction, so a failure partway through a
file never leaves the tracking table lying about what actually happened.
Now write the SQL itself. Create repository/migrations/0001_create_tables.sql:
CREATE TABLE users (
id UUID PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL
);
CREATE TABLE orders (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),
item TEXT NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0),
status TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX orders_user_id_idx ON orders(user_id);
And repository/migrations/0002_seed_users.sql, so there’s something to log in against before
chapter 9 exists — two accounts, both with the password password123,
bcrypt-hashed for real rather than a placeholder string:
INSERT INTO users (id, username, password_hash) VALUES
('11111111-1111-1111-1111-111111111111', 'alice', '$2a$10$7zAE.OJ3lyD/3Eq4T2XMV.zU162KDzXOQnpHXE/kkd00GSQ.V4I3.'),
('22222222-2222-2222-2222-222222222222', 'bob', '$2a$10$SHJKrTmOvH8SK6p6gGxu9e3.d7Z3DuDbhHOFQ22G0E5zz0GEj3YM.');
A real service would never ship credentials in a migration file — this exists purely because a signup flow is out of scope for this tutorial, and the login endpoint needs something to check against.
Run it against a real database
This is the first chapter where there’s something worth actually running. Start Postgres:
$ make up
And run the tests:
$ make test-integration
=== RUN TestNewFailsWhenARequiredVarIsMissing
--- PASS: TestNewFailsWhenARequiredVarIsMissing (0.00s)
=== RUN TestNewAppliesDefaultsAndParsesTypedFields
--- PASS: TestNewAppliesDefaultsAndParsesTypedFields (0.00s)
PASS
ok example.com/servoorders/internal/config 0.351s
? example.com/servoorders/internal/domain [no test files]
? example.com/servoorders/internal/repository/migrations [no test files]
=== RUN TestCreateAndGetOrder
--- PASS: TestCreateAndGetOrder (0.02s)
=== RUN TestGetMissingOrderReturnsErrNotFound
--- PASS: TestGetMissingOrderReturnsErrNotFound (0.01s)
=== RUN TestListByUserOrdersMostRecentFirst
--- PASS: TestListByUserOrdersMostRecentFirst (0.01s)
=== RUN TestGetByUsernameFindsSeededUser
--- PASS: TestGetByUsernameFindsSeededUser (0.01s)
=== RUN TestGetByUsernameUnknownReturnsErrNotFound
--- PASS: TestGetByUsernameUnknownReturnsErrNotFound (0.01s)
PASS
ok example.com/servoorders/internal/repository/postgres 0.376s
? example.com/servoorders/internal/repository [no test files]
The postgres tests are worth writing yourself, in repository/postgres/postgres_test.go — but first, a small
helper that every one of them shares, and that’s worth understanding before the tests that call it:
func testStore(t *testing.T) *Store {
t.Helper()
dsn := os.Getenv("TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("TEST_POSTGRES_DSN not set; see docs/tutorial/05-repository-layer.md")
}
s, err := New(&Config{DSN: dsn})
if err != nil {
t.Fatalf("New: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := s.Init(ctx); err != nil {
t.Fatalf("Init (is Postgres running and reachable at %s?): %v", dsn, err)
}
t.Cleanup(func() { s.Stop(context.Background()) })
return s
}
Without make up first, the same command doesn’t fail — it just quietly finds nothing to test:
$ go test ./postgres/...
ok example.com/servoorders/internal/repository/postgres 0.174s
Add -v to see why it was that fast:
$ go test ./postgres/... -v
--- SKIP: TestCreateAndGetOrder (0.00s)
postgres_test.go:40: TEST_POSTGRES_DSN not set; see docs/tutorial/05-repository-layer.md
...
PASS
That skip-with-a-pointer, instead of a failure, is testStore’s whole job. And every time it
does run, it’s also proving the migrations apply cleanly on a fresh database — Init runs them
every time, so a passing test already tells you the schema and the seed data are both correct.
Diagnostics
postgres: ping: failed to connect ... connection refused— Postgres isn’t running, or isn’t listening where the DSN says. Confirmmake upactually succeeded (docker ps), and thatPOSTGRES_DSN’s host and port match the compose file’s.could not create directory "/var/lib/postgresql/data/pg_wal": No space left on device(in the container’s own logs, not your Go program) — this isn’t your host disk; it’s Docker Desktop’s VM disk allocation, usually filled by other, unrelated projects’ volumes and images. Checkdocker system df— ifLocal Volumesshows a large reclaimable amount, something else on your machine is the cause. Don’t blindlydocker volume prune; checkdocker volume lsfirst so you don’t delete a different project’s data.pgx.ErrNoRowsleaking out as a generic error — every query that can legitimately return zero rows for “not found” (as opposed to “empty list”) needs theerrors.Is(err, pgx.ErrNoRows)check fromGetandGetByUsernameabove. Forgetting it turns a clean 404 into a confusing 500, three layers up.- Migrations show as “already applied” but the schema looks wrong — the tracking table only
records that a file ran, not that every session of running it succeeded end to end. If you edit
an already-applied migration file,
Runwon’t re-apply it — add a new file instead. Never edit a migration that’s already shipped.
Do’s and don’ts
- Do run migrations from
Init, not a separate manual step, for a service this size — one less thing to forget before a deploy. A larger service with its own release pipeline often runs migrations as a distinct step instead, so schema changes and code deploys can be reasoned about independently; that’s a real difference in scale, not a correction to what we just built. - Do wrap every persistence error with
fmt.Errorf("postgres: ...: %w", err)— the prefix makes a stack of wrapped errors readable in a single log line, and%wkeepserrors.Is/errors.Asworking through it. - Don’t let SQL live anywhere but this package. A query written directly in the service layer “just this once” is how the interface boundary quietly stops meaning anything.
- Don’t use
SELECT *. Naming columns explicitly means adding a column to the table later doesn’t silently change whatScanexpects.
Next
Chapter 6: Caching layer — Redis, and the cache-aside pattern this service uses for order lookups.