background-shape
Go article cover illustration on a gradient background
June 13, 2022 · 5 min read · by Muhammad Amal programming
Advertisement

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:

Advertisement
// 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.

Advertisement