background-shape
Repositories and Interfaces in Go
June 13, 2022 · 5 min read · by Muhammad Amal programming

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:

  1. Domain errors: domain.ErrNotFound, etc. Returned when the situation is meaningful to the use case.
  2. Transient infra errors: connection lost, timeout. Surface as-is for the use case to decide (retry? abort?).
  3. 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.