Опирается на правила: R-SQLC-MAP-1, R-SQLC-MAP-2, R-SQLC-MAP-3, R-SQLC-MAP-X1, R-SQLC-MAP-X2, R-SQLC-NEST-1R-SQLC-NEST-4, R-SQLC-NEST-X1, R-SQLC-NEST-X2 из sqlc Style Guide → раздел 3. Маппинг sqlc ↔ domain.

Важно знать

  • Сгенерированный sqlc-тип (db.Order, db.GetOrderByIDRow) — деталь persistence. Наружу из репозитория выходит только доменный объект.
  • Маппер — отдельный файл order_mapper.go в пакете persistence, не методы репозитория.
  • toDomain конвертирует строку sqlc → доменный агрегат; toInsertParams — доменный объект → параметры sqlc-запроса.
  • Сборка агрегата из нескольких строк (nested-fetch) — тоже в маппере: map[uuid.UUID]*Order + цикл, порядок сохраняется через ordered []uuid.UUID.
  • Маппер не содержит бизнес-логики — только структурная конвертация; никаких if o.Status == "cancelled" внутри.
  • reflect/json.Marshal-Unmarshal для маппинга запрещены: нет безопасности типов, ошибки обнаруживаются в рантайме.
  • Nullable-поля — pgtype.* или кастомный тип из overrides в sqlc.yaml; не sql.NullString.

В sqlc генерация создаёт ровно то, что описано в SQL-запросе: структуры со строго типизированными полями, привязанными к колонкам. Эти структуры — точное отражение схемы PostgreSQL. Доменные объекты — отражение бизнес-модели. Задача маппера — соединить их так, чтобы ни persistence, ни домен не знали друг о друге.

Структура файлов

Маппер живёт в том же пакете, что и репозиторий, но в отдельном файле:

adapters/out/persistence/
    postgres_order_repository.go   // реализация порта
    order_mapper.go                // toDomain + toInsertParams

order_mapper.gopackage persistence, непубличные функции. Репозиторий вызывает их напрямую, без экспорта наружу.

Простой случай: одна строка → агрегат

Для домена Order из Sber-процессинга (создание платёжного поручения):

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

import (
    "github.com/google/uuid"
    "yourapp/core/order"
    "yourapp/db"
)

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,
    }
}

Функции не возвращают ошибку — конвертация структурная, не валидирующая. Если order.Status(row.Status) даёт невалидный статус, значит схема БД и доменная модель разошлись: это ошибка на уровне миграций, не маппера.

Репозиторий вызывает маппер в одну строку:

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
}

Разные запросы — разные маппер-функции

sqlc генерирует отдельный тип строки для каждого запроса: GetOrderByIDRow, ListOrdersByCustomerRow. Маппер пишет функцию под каждый тип, не пытается унифицировать через interface{}:

// для GetOrderByID :one
func toDomain(row db.GetOrderByIDRow) *order.Order { ... }

// для ListOrdersByCustomer :many — тот же домен, другая sqlc-строка
func toDomainFromList(row db.ListOrdersByCustomerRow) *order.Order {
    return &order.Order{
        ID:         row.ID,
        CustomerID: row.CustomerID,
        Amount:     row.Amount,
        Status:     order.Status(row.Status),
        CreatedAt:  row.CreatedAt,
    }
}

Дублирование здесь лучше абстракции через reflect: при изменении схемы компилятор сразу укажет, какой маппер сломался.

Nested-fetch: сборка агрегата из flat-результата

Когда Order содержит вложенную коллекцию []Item, используем JOIN-запрос с :many и собираем агрегаты в маппере.

SQL-запрос:

-- 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
}

Детали реализации:

  • index — быстрый поиск уже созданного агрегата по OrderID.
  • ordered — срез UUID в порядке первого появления. Без него итерация по map даёт случайный порядок, нарушая ORDER BY из SQL.
  • Финальный цикл восстанавливает порядок из ordered.

Репозиторий вызывает маппер после получения всех строк:

func (r *PostgresOrderRepository) ListByCustomer(ctx context.Context, customerID uuid.UUID) ([]*order.Order, error) {
    rows, err := r.q.ListOrdersWithItems(ctx, customerID)
    if err != nil {
        return nil, fmt.Errorf("list orders for customer %s: %w", customerID, err)
    }
    return toOrdersWithItems(rows), nil
}

Когда нужны два запроса вместо JOIN

JOIN хорошо работает, когда вложенная коллекция небольшая. Для сложных агрегатов с несколькими уровнями вложенности (например, Product[]Variant[]VariantOption в домене Sber Marketplace) JOIN-декартово произведение раздувает объём данных. Тогда два запроса в рамках одной транзакции:

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

    variantRows, err := r.q.ListVariantsByProduct(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("list variants for product %s: %w", id, err)
    }

    return toProductWithVariants(row, variantRows), nil
}

Маппер toProductWithVariants принимает оба результата и собирает агрегат:

func toProductWithVariants(row db.GetProductByIDRow, variants []db.ListVariantsByProductRow) *product.Product {
    p := &product.Product{
        ID:    row.ID,
        Name:  row.Name,
        Price: row.Price,
    }
    for _, v := range variants {
        p.Variants = append(p.Variants, product.Variant{
            ID:    v.ID,
            Label: v.Label,
            SKU:   v.Sku,
        })
    }
    return p
}

Транзакция (оба запроса в одном pgx.Tx) гарантирует консистентность снимка. Репозиторий получает pgx.Tx через WithTx от handler'а.

Read-проекции: отдельный маппер, отдельный репозиторий

CQRS-запрос возвращает не агрегат, а read-DTO. Для него — отдельный маппер, не переиспользующий toDomain:

// adapters/out/persistence/order_view_mapper.go
package persistence

func toOrderSummary(row db.ListOrderSummariesRow) order.OrderSummary {
    return order.OrderSummary{
        ID:          row.ID,
        Status:      order.Status(row.Status),
        TotalAmount: row.TotalAmount,
        ItemCount:   int(row.ItemCount),
        CreatedAt:   row.CreatedAt,
    }
}

order.OrderSummary — read-DTO в core/order/, не агрегат. Он не имеет доменных методов, только поля для API-ответа. Репозиторий для проекций — PostgresOrderViewRepository, отдельный от PostgresOrderRepository.

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

АнтипаттернПравилоЧто взамен
json.Marshal(row); json.Unmarshal(..., &domainObj) для конвертацииR-SQLC-MAP-X1Явные поля toDomain(row db.X) *domain.Y
Возврат db.Order из публичного метода репозиторияR-SQLC-MAP-X2Маппинг до выхода из репозитория, наружу — только доменный тип
Бизнес-проверка внутри маппера (if row.Status == "cancelled" { ... })R-SQLC-MAP-3Логика в доменных методах, маппер — только конвертация полей
N+1: for _, id := range ids { repo.FindByID(ctx, id) }R-SQLC-NEST-X1JOIN + toOrdersWithItems или WHERE id = ANY($1)
Загрузка полного агрегата для read-only проекцииR-SQLC-NEST-X2Отдельный <X>ViewRepository с read-DTO маппером
reflect.ValueOf(row) для generic-маппингаR-SQLC-MAP-X1Отдельная функция для каждого sqlc-типа

Куда дальше

  • go/repository-pattern.md — как организован PostgresOrderRepository, порт в core/, WithTx, конструктор.
  • go/transactions.md — почему транзакция открывается на handler'е, а не в репозитории; defer tx.Rollback(ctx) и tx.Commit(ctx).
  • Маппинг record ↔ domain в jOOQ — Java-аналог: assembleAggregate, делегация child-маппера; архитектурные решения те же, инструмент другой.