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.