Опирается на правила: R-SQLC-REPO-1R-SQLC-REPO-4, R-SQLC-REPO-X1R-SQLC-REPO-X3, R-SQLC-MAP-1R-SQLC-MAP-3, R-SQLC-ERR-1R-SQLC-ERR-3 из sqlc Style Guide → раздел 1. Repository-pattern.

Важно знать

  • Repository — это две сущности: interface в core/<bc>/port/ и Postgres<X>Repository в adapters/out/persistence/. Домен зависит только от интерфейса.
  • На вход и выход публичных методов — доменные объекты (Aggregate, Value Object, read-DTO). Сгенерированные sqlc-типы (db.Order, db.GetOrderByIDRow) остаются внутри адаптера.
  • *db.Queries и *pgxpool.Pool инжектируются конструктором — не создаются внутри репозитория.
  • В репозитории нет бизнес-логики: никаких if order.Status == ..., никаких вызовов внешних сервисов.
  • pgx.ErrNoRows → доменная NotFoundError; нарушение 23505 → ошибка конфликта; остальные pgx-ошибки — техническая обёртка через %w.
  • Mock pgx.Tx / pgxpool.Pool запрещён — тесты пишутся против Testcontainers Postgres.
  • Граница транзакции — на Handler, не внутри репозитория.

Repository в этом подходе — граница между domain-слоем и persistence-слоем. Domain знает «как устроен Order», persistence знает «как достать Order из PostgreSQL». Всё, что выходит из adapters/out/persistence/, уже превращено в доменный объект; внутрь домена sqlc-типы не проникают, бизнес-логика в persistence не проникает.

Это не зеркало jOOQ-статьи — sqlc и pgx/v5 предлагают другие идиомы: *db.Queries вместо DSLContext, явная передача pgx.Tx через WithTx, функции-маппера вместо RecordMapper-бинов, errors.As для навигации по pgx-ошибкам.

Доменный порт отдельно от реализации

R-SQLC-REPO-1: интерфейс в core/<bc>/port/, реализация в adapters/out/persistence/.

// core/order/port/order_repository.go
package port

type OrderRepository interface {
    Save(ctx context.Context, o *order.Order) error
    FindByID(ctx context.Context, id uuid.UUID) (*order.Order, error)
    ListByCustomer(ctx context.Context, customerID uuid.UUID, limit, offset int) ([]*order.Order, error)
}
// adapters/out/persistence/postgres_order_repository.go
package persistence

type PostgresOrderRepository struct {
    q    *db.Queries
    pool *pgxpool.Pool
}

func NewPostgresOrderRepository(pool *pgxpool.Pool) *PostgresOrderRepository {
    return &PostgresOrderRepository{q: db.New(pool), pool: pool}
}

Handler инжектирует port.OrderRepository, не *PostgresOrderRepository — это и есть смысл разделения. Если в будущем появится RedisOrderRepository (cache-aside), handler не меняется.

*db.Queries хранит пул и предоставляет все сгенерированные методы. pool нужен отдельно — для CopyFrom при bulk-операциях и для Begin(ctx) на стороне Handler.

Публичные методы — только доменные типы

R-SQLC-REPO-2: на входе и выходе порта — *order.Order, uuid.UUID, примитивы Go. Сгенерированный тип db.GetOrderByIDRow — деталь реализации.

// ПРАВИЛЬНО
FindByID(ctx context.Context, id uuid.UUID) (*order.Order, error)
ListByCustomer(ctx context.Context, customerID uuid.UUID, limit, offset int) ([]*order.Order, error)

// НЕПРАВИЛЬНО — sqlc-тип протёк в контракт порта
FindByID(ctx context.Context, id uuid.UUID) (db.Order, error)
FindRaw(ctx context.Context, id uuid.UUID) (pgx.Rows, error)

Handler работает с *order.Order, вызывает o.CanBeCancelled() — доменный метод. Если бы handler получал db.Order, бизнес-проверки растеклись бы по handler'ам без доменного объекта.

Маппер рядом с репозиторием

R-SQLC-MAP-1: явные функции toDomain и toInsertParams в отдельном файле рядом с репозиторием — не внутри него.

// adapters/out/persistence/order_mapper.go
package persistence

func toDomain(row db.GetOrderByIDRow) *order.Order {
    return &order.Order{
        ID:         row.ID,
        CustomerID: row.CustomerID,
        Amount:     row.Amount,
        Status:     order.Status(row.Status),
        CreatedAt:  row.CreatedAt,
    }
}

func toInsertParams(o *order.Order) db.InsertOrderParams {
    return db.InsertOrderParams{
        ID:         o.ID,
        CustomerID: o.CustomerID,
        Amount:     o.Amount,
        Status:     string(o.Status),
        CreatedAt:  o.CreatedAt,
    }
}

R-SQLC-MAP-3: маппер — только структурная конвертация полей. Никакого if o.Status == order.StatusCancelled { ... } — это бизнес-логика, ей не место здесь.

Две функции вместо одного класса-маппера (как в Java/Spring) — Go-идиома. Функции пакета persistence имеют доступ к обоим типам и не несут состояния.

Обработка ошибок pgx

R-SQLC-ERR-1 и R-SQLC-ERR-2 — переводить pgx-коды в доменные ошибки на границе адаптера.

// adapters/out/persistence/postgres_order_repository.go

func (r *PostgresOrderRepository) FindByID(ctx context.Context, id uuid.UUID) (*order.Order, error) {
    row, err := r.q.GetOrderByID(ctx, id)
    if err != nil {
        if errors.Is(err, pgx.ErrNoRows) {
            return nil, &order.NotFoundError{ID: id}
        }
        return nil, fmt.Errorf("find order %s: %w", id, err)
    }
    return toDomain(row), nil
}

func (r *PostgresOrderRepository) Save(ctx context.Context, o *order.Order) error {
    err := r.q.InsertOrder(ctx, toInsertParams(o))
    if err != nil {
        var pgErr *pgconn.PgError
        if errors.As(err, &pgErr) && pgErr.Code == "23505" {
            return &order.AlreadyExistsError{ID: o.ID}
        }
        return fmt.Errorf("save order %s: %w", o.ID, err)
    }
    return nil
}

pgx.ErrNoRows — сигнал «записи нет». Возврат nil, nil запрещён (R-SQLC-ERR-X1) — вызывающий получает nil-агрегат без сигнала об ошибке, что ломает контракт порта.

errors.As(err, &pgErr) — стандартная Go-идиома для навигации по цепочке ошибок (%w). pgErr.Code == "23505" — нарушение уникальности; "23503" — нарушение внешнего ключа (доменная ошибка бизнес-правила).

R-SQLC-ERR-3: pgx-ошибки не логируются в репозитории, только оборачиваются через %w и всплывают вверх. Логирование на edge (middleware), не на каждом уровне стека.

Nested-fetch: сборка агрегата с вложенными сущностями

R-SQLC-NEST-1 и R-SQLC-NEST-2: загрузка Order с его Items — один JOIN-запрос, сборка flat-результата в маппере через map[uuid.UUID]*order.Order.

-- db/queries/orders.sql

-- name: ListOrdersWithItems :many
SELECT
    o.id          AS order_id,
    o.customer_id,
    o.status,
    oi.id         AS item_id,
    oi.product_id,
    oi.quantity,
    oi.price
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.customer_id = $1
ORDER BY o.created_at DESC;
// adapters/out/persistence/order_mapper.go

func toOrdersWithItems(rows []db.ListOrdersWithItemsRow) []*order.Order {
    index := make(map[uuid.UUID]*order.Order)
    var ordered []uuid.UUID

    for _, row := range rows {
        if _, ok := index[row.OrderID]; !ok {
            index[row.OrderID] = &order.Order{
                ID:         row.OrderID,
                CustomerID: row.CustomerID,
                Status:     order.Status(row.Status),
            }
            ordered = append(ordered, row.OrderID)
        }
        index[row.OrderID].Items = append(index[row.OrderID].Items, order.Item{
            ID:        row.ItemID,
            ProductID: row.ProductID,
            Quantity:  int(row.Quantity),
            Price:     row.Price,
        })
    }

    result := make([]*order.Order, 0, len(ordered))
    for _, id := range ordered {
        result = append(result, index[id])
    }
    return result
}

ordered []uuid.UUID хранит порядок появления заказов в flat-результате — без него map нарушит сортировку ORDER BY o.created_at DESC.

func (r *PostgresOrderRepository) ListByCustomer(ctx context.Context, customerID uuid.UUID, limit, offset int) ([]*order.Order, error) {
    rows, err := r.q.ListOrdersWithItems(ctx, db.ListOrdersWithItemsParams{
        CustomerID: customerID,
        Limit:      int32(limit),
        Offset:     int32(offset),
    })
    if err != nil {
        return nil, fmt.Errorf("list orders by customer %s: %w", customerID, err)
    }
    return toOrdersWithItems(rows), nil
}

R-SQLC-NEST-X1: цикл for _, id := range orderIDs { repo.FindByID(ctx, id) } — N+1 запросов, запрещено. Один запрос с JOIN или WHERE id = ANY($1).

Что запрещено

АнтипаттернПравилоЧто взамен
Возврат db.Order / db.GetOrderByIDRow из метода портаR-SQLC-REPO-X1Доменный тип *order.Order через toDomain
Бизнес-логика внутри репозитория (if order.Status == ...)R-SQLC-REPO-X2Логика в доменных методах агрегата
Использование *db.Queries или *pgxpool.Pool в core/ напрямуюR-SQLC-REPO-X3Только через port.OrderRepository
return nil, nil при pgx.ErrNoRowsR-SQLC-ERR-X1return nil, &order.NotFoundError{ID: id}
Логирование pgx-ошибки в репозитории + проброс вверхR-SQLC-ERR-X2Только fmt.Errorf("...: %w", err), лог — на edge
reflect / json.Marshal для маппинга sqlc → domainR-SQLC-MAP-X1Явные функции toDomain / toInsertParams
N+1: цикл repo.FindByID по списку IDR-SQLC-NEST-X1Один запрос с WHERE id = ANY($1) или JOIN

Куда дальше

  • Транзакции в Go — pgx.Tx на handler'е — как Handler открывает tx, передаёт через WithTx, делает defer tx.Rollback.
  • sqlc codegen и query-файлы — sqlc.yaml, аннотации :one|:many|:exec, overrides для UUID/numeric/timestamptz.
  • Nested-fetch и связанные агрегаты — R-SQLC-NEST-* подробно: JOIN vs два запроса, array_agg, batchexec.
  • PostgreSQL: ACID и уровни изоляции — теория за read-only транзакциями и pgx.TxOptions.