Repositories and Interfaces in Go
TL;DR — Repository = interface in use-case layer defining what the layer needs. Implementation in adapter layer talks to the actual DB. Methods accept
context.Context, return domain entities. Transactions via a small executor abstraction. No leaky DB types.
After domain entities, the way use cases reach persistent storage. The repository pattern in Go is straightforward once you stop fighting the language.
The interface lives in use-case
Already mentioned in earlier posts. Repeating because it’s the rule that matters:
// internal/usecase/port.go
package usecase
import (
"context"
"github.com/google/uuid"
"yourorg/billing/internal/domain"
)
type SubscriptionRepository interface {
Save(ctx context.Context, s *domain.Subscription) error
GetByID(ctx context.Context, id uuid.UUID) (*domain.Subscription, error)
ListByCustomer(ctx context.Context, customerID uuid.UUID) ([]*domain.Subscription, error)
}
Three things:
- Methods accept
context.Context(cancellation, deadlines) - Inputs are domain types (
*domain.Subscription,uuid.UUID) - Outputs are domain types (no DB row structs leaking)
The interface defines what the use case needs, no more. Don’t pre-emptively add methods. YAGNI.
The Postgres implementation
// internal/adapter/postgres/subscription_repository.go
package postgres
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/pgxpool"
"yourorg/billing/internal/domain"
)
type SubscriptionRepository struct {
db *pgxpool.Pool
}
func NewSubscriptionRepository(db *pgxpool.Pool) *SubscriptionRepository {
return &SubscriptionRepository{db: db}
}
func (r *SubscriptionRepository) Save(ctx context.Context, s *domain.Subscription) error {
_, err := r.db.Exec(ctx, `
INSERT INTO subscriptions (id, customer_id, plan_id, status, started_at, renews_at, canceled_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO UPDATE SET
status = EXCLUDED.status,
renews_at = EXCLUDED.renews_at,
canceled_at = EXCLUDED.canceled_at
`, s.ID(), s.CustomerID(), s.Plan().ID, string(s.Status()), s.StartedAt(), s.RenewsAt(), s.CanceledAt())
return err
}
func (r *SubscriptionRepository) GetByID(ctx context.Context, id uuid.UUID) (*domain.Subscription, error) {
var row subscriptionRow
err := r.db.QueryRow(ctx, `
SELECT id, customer_id, plan_id, status, started_at, renews_at, canceled_at
FROM subscriptions WHERE id = $1
`, id).Scan(&row.ID, &row.CustomerID, &row.PlanID, &row.Status, &row.StartedAt, &row.RenewsAt, &row.CanceledAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrSubscriptionNotFound
}
if err != nil {
return nil, err
}
return row.toDomain()
}
type subscriptionRow struct {
ID uuid.UUID
CustomerID uuid.UUID
PlanID string
Status string
StartedAt time.Time
RenewsAt time.Time
CanceledAt *time.Time
}
func (r subscriptionRow) toDomain() (*domain.Subscription, error) {
plan, err := loadPlan(r.PlanID) // separate concern
if err != nil {
return nil, err
}
return domain.RestoreSubscription(
r.ID, r.CustomerID, plan,
domain.Status(r.Status),
r.StartedAt, r.RenewsAt, r.CanceledAt,
), nil
}
The subscriptionRow is a private struct that exists only in the adapter to deserialize DB rows. The toDomain() method converts to the domain type. Domain types never know about Postgres.
Note: returns domain.ErrSubscriptionNotFound (defined in domain) when pgx says no rows. Caller checks via errors.Is; doesn’t need to know we’re using pgx.
Transactions
The tricky one. A use case sometimes needs to do “save subscription + publish event” atomically. How without leaking pgx into the use case?
Pattern: Executor interface.
// In usecase/port.go
type Tx interface {
Subscription() SubscriptionRepository
Invoice() InvoiceRepository
Commit(ctx context.Context) error
Rollback(ctx context.Context) error
}
type TxManager interface {
Begin(ctx context.Context) (Tx, error)
}
In Postgres adapter:
type PgTx struct {
tx pgx.Tx
}
func (t *PgTx) Subscription() usecase.SubscriptionRepository {
return &SubscriptionRepository{db: txDB{t.tx}} // adapter that uses tx instead of pool
}
type PgTxManager struct {
pool *pgxpool.Pool
}
func (m *PgTxManager) Begin(ctx context.Context) (usecase.Tx, error) {
tx, err := m.pool.Begin(ctx)
if err != nil { return nil, err }
return &PgTx{tx: tx}, nil
}
Use case:
func (u *Refund) Execute(ctx context.Context, ...) error {
tx, err := u.txMgr.Begin(ctx)
if err != nil { return err }
defer tx.Rollback(ctx) // safe to call after commit
if err := tx.Subscription().Save(ctx, sub); err != nil { return err }
if err := tx.Invoice().Save(ctx, invoice); err != nil { return err }
return tx.Commit(ctx)
}
The use case doesn’t know it’s Postgres. Tx is an interface. The pattern works for any DB.
Alternative: pass Tx into repository methods explicitly. Either works; the interface-per-tx pattern is cleaner.
Avoid the urge to generalize
Tempting to write type Repository[T any] interface { Save(T) error; Get(id) T }. Generic. Reusable. Bad.
Generic repositories accumulate methods nobody needs (FindAll, FindByCriteria, etc.). Each use case wants specific methods. Define specific interfaces per consumer.
Go 1.18 has generics; doesn’t mean every type should use them. Repositories are usually wrong place for them.
Error normalization
Three error categories repositories should distinguish:
- Domain errors:
domain.ErrNotFound, etc. Returned when the situation is meaningful to the use case. - Transient infra errors: connection lost, timeout. Surface as-is for the use case to decide (retry? abort?).
- Constraint violations: unique key, FK. Map to domain errors when meaningful (e.g., duplicate subscription →
domain.ErrDuplicate).
func (r *SubscriptionRepository) Save(ctx context.Context, s *domain.Subscription) error {
_, err := r.db.Exec(ctx, `INSERT ...`, ...)
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return domain.ErrDuplicateSubscription
}
return err
}
Common Pitfalls
Returning DB row structs from public methods. Adapter detail leaks. Map to domain.
Save() that takes raw fields instead of a domain entity. Defeats encapsulation. Pass *domain.Subscription.
N+1 queries hidden inside repository methods. Repository should expose batch operations: GetByIDs(ctx, ids), ListByCustomer(ctx, id). Caller decides.
No context.Context. Now you can’t cancel, can’t propagate deadlines, can’t trace. Always first arg.
Interface methods with 8+ parameters. Wrap inputs in struct: Save(ctx, input SaveInput). Clearer.
Mock generation as default test approach. Hand-rolled fakes are usually clearer.
Wrapping Up
Repository = port in use-case layer + implementation in adapter layer + tx pattern for atomicity. Domain types only on the public surface. Wednesday: use cases — what calls these repositories.