Опирается на правила: R-SQLC-NEST-1R-SQLC-NEST-4 и R-SQLC-NEST-X1R-SQLC-NEST-X2 из sqlc Style Guide → раздел 7. Nested-fetch и связанные агрегаты.

Важно знать

  • Загрузка агрегата с вложенными сущностями — одним SQL-запросом с JOIN, не циклом обращений к репозиторию.
  • Flat-результат JOIN собирается в map[uuid.UUID]*Order внутри маппера, не внутри репозитория.
  • Порядок элементов сохраняется через отдельный []uuid.UUID orderedmap порядок не гарантирует.
  • json_agg / array_agg — альтернатива для плоских вложений; для сложных агрегатов предпочтительнее два запроса в одной транзакции.
  • Read-проекции (CQRS query-side) — в отдельном <X>ViewRepository, возвращают read-DTO, не полный агрегат.
  • N+1 через WHERE id = ANY($1) — допустим для вспомогательных коллекций, когда JOIN слишком дорог; избегать цикл FindByID per-item.
  • Маппер не содержит бизнес-логики: только структурная конвертация из []db.Row в []*domain.Order.

sqlc генерирует Go-код из SQL-запросов, не строит SQL из API. Это означает, что nested-fetch — задача проектирования запроса, а не поиска «правильного метода». JOIN пишется вручную в .sql-файле; из flat-результата агрегат собирается в маппере. Отличие от jOOQ multiset(): здесь нет встроенной поддержки вложенных коллекций — нужен явный map-редьюсер.

Проблема N+1 и её устранение

R-SQLC-NEST-X1 описывает антипаттерн, который возникает сам собой, если не думать о нём заранее.

Дано: нужно загрузить список заказов с позициями.

// ПЛОХО — N+1: один запрос за списком + по одному за каждым агрегатом
orders, _ := repo.ListByCustomer(ctx, customerID)
for _, o := range orders {
    items, _ := itemRepo.FindByOrderID(ctx, o.ID)  // N round-trips
    o.Items = items
}

Для 50 заказов — 51 запрос к PostgreSQL. При p99 latency 2 мс каждый — >100 мс накладных расходов, без учёта сетевого overhead.

Правильный путь (R-SQLC-NEST-1): один запрос с JOIN, flat-результат — в маппере.

-- db/queries/orders.sql

-- name: ListOrdersWithItems :many
SELECT
    o.id          AS order_id,
    o.customer_id,
    o.status,
    o.created_at,
    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, oi.id;

sqlc сгенерирует тип ListOrdersWithItemsRow с полями из обеих таблиц.

Сборка агрегата из flat-результата

R-SQLC-NEST-2 и R-SQLC-MAP-2: сборка вложенных коллекций — в маппере, не в репозитории.

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

import (
    "github.com/google/uuid"
    "core/order"
    "adapters/out/persistence/db"
)

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),
                CreatedAt:  row.CreatedAt,
            }
            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 — сохраняет порядок первого появления order_id. Итерация по map даёт произвольный порядок; SQL-сортировка ORDER BY o.created_at DESC значима только если порядок сохраняется через этот срез.
  • Маппер в отдельном файле order_mapper.go, не внутри репозитория. Репозиторий вызывает маппер:
// adapters/out/persistence/postgres_order_repository.go
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 customer=%s: %w", customerID, err)
    }
    return toOrdersWithItems(rows), nil
}

json_agg для простых вложений

R-SQLC-NEST-3: когда вложение простое — единственный массив скалярных значений или мелких структур — json_agg сокращает код маппера.

Пример: продукт с массивом тегов в домене Sber.

-- db/queries/products.sql

-- name: GetProductWithTags :one
SELECT
    p.id,
    p.name,
    p.price,
    json_agg(t.name ORDER BY t.name) FILTER (WHERE t.name IS NOT NULL) AS tags
FROM products p
LEFT JOIN product_tags pt ON pt.product_id = p.id
LEFT JOIN tags t ON t.id = pt.tag_id
WHERE p.id = $1
GROUP BY p.id;

sqlc с overrides для json_agg вернёт pgtype.Text или json.RawMessage. Маппер разбирает JSON:

// adapters/out/persistence/product_mapper.go
package persistence

import (
    "encoding/json"
    "core/product"
    "adapters/out/persistence/db"
)

func toProductWithTags(row db.GetProductWithTagsRow) (*product.Product, error) {
    var tags []string
    if len(row.Tags) > 0 {
        if err := json.Unmarshal(row.Tags, &tags); err != nil {
            return nil, fmt.Errorf("unmarshal product tags: %w", err)
        }
    }
    return &product.Product{
        ID:    row.ID,
        Name:  row.Name,
        Price: row.Price,
        Tags:  tags,
    }, nil
}

Когда json_agg не подходит:

  • Вложенные объекты сами содержат вложения (глубже двух уровней).
  • Маппинг JSON усложняется — быстрее написать два запроса + map-редьюсер.

Два запроса в одной транзакции для сложных агрегатов

R-SQLC-NEST-3 (вторая часть): для сложных агрегатов — два отдельных запроса в рамках одной транзакции.

Пример: заказ в домене Customer360 с позициями и историей статусов.

-- name: GetOrderByID :one
SELECT id, customer_id, status, total_amount, created_at
FROM orders
WHERE id = $1;

-- name: ListOrderItemsByOrderID :many
SELECT id, product_id, quantity, price
FROM order_items
WHERE order_id = $1
ORDER BY id;

-- name: ListOrderStatusHistoryByOrderID :many
SELECT id, status, changed_at, changed_by
FROM order_status_history
WHERE order_id = $1
ORDER BY changed_at;

Репозиторий вызывает оба запроса через один *db.Queries, связанный с транзакцией:

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

    items, err := r.q.ListOrderItemsByOrderID(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("list items order=%s: %w", id, err)
    }

    history, err := r.q.ListOrderStatusHistoryByOrderID(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("list status history order=%s: %w", id, err)
    }

    return toOrderFull(row, items, history), nil
}

Граница транзакции — на Handler (R-SQLC-TX-1). r.q создан через r.q.WithTx(tx), поэтому все три запроса идут в одной транзакции, инициированной снаружи:

// core/order/handler/get_order_handler.go
func (h *GetOrderHandler) Handle(ctx context.Context, cmd GetOrderCommand) (*order.Order, error) {
    tx, err := h.pool.Begin(ctx)
    if err != nil {
        return nil, fmt.Errorf("begin tx: %w", err)
    }
    defer tx.Rollback(ctx)

    repo := h.repo.WithTx(tx)
    result, err := repo.FindByID(ctx, cmd.OrderID)
    if err != nil {
        return nil, err
    }
    if err := tx.Commit(ctx); err != nil {
        return nil, fmt.Errorf("commit tx: %w", err)
    }
    return result, nil
}

WHERE id = ANY($1) для коллекции идентификаторов

Когда агрегаты независимы и JOIN нецелесообразен — одним запросом через ANY:

-- name: ListOrdersByIDs :many
SELECT id, customer_id, status, created_at
FROM orders
WHERE id = ANY($1::uuid[])
ORDER BY created_at DESC;

В Go передаётся []uuid.UUID:

func (r *PostgresOrderRepository) FindByIDs(
    ctx context.Context,
    ids []uuid.UUID,
) ([]*order.Order, error) {
    if len(ids) == 0 {
        return nil, nil
    }
    rows, err := r.q.ListOrdersByIDs(ctx, ids)
    if err != nil {
        return nil, fmt.Errorf("find orders by ids: %w", err)
    }
    result := make([]*order.Order, 0, len(rows))
    for _, row := range rows {
        result = append(result, toDomain(row))
    }
    return result, nil
}

Этот паттерн применяется когда хендлер уже имеет список ID из другого источника (например, из Kafka-события или из списка связей).

Read-проекция — отдельный ViewRepository

R-SQLC-NEST-4 и R-SQLC-NEST-X2: read-only проекция не загружает полный агрегат.

-- db/queries/order_view.sql

-- name: ListOrderSummariesByCustomer :many
SELECT
    o.id          AS order_id,
    o.status,
    o.total_amount,
    COUNT(oi.id)  AS item_count,
    c.full_name   AS customer_name,
    o.created_at
FROM orders o
JOIN customers c   ON c.id = o.customer_id
LEFT JOIN order_items oi ON oi.order_id = o.id
WHERE o.customer_id = $1
GROUP BY o.id, c.full_name
ORDER BY o.created_at DESC
LIMIT $2 OFFSET $3;
// adapters/out/persistence/postgres_order_view_repository.go
package persistence

import (
    "context"
    "fmt"

    "github.com/google/uuid"
    "core/order/dto/view"
    "adapters/out/persistence/db"
)

type PostgresOrderViewRepository struct {
    q *db.Queries
}

func (r *PostgresOrderViewRepository) ListSummariesByCustomer(
    ctx context.Context,
    customerID uuid.UUID,
    limit, offset int32,
) ([]view.OrderSummaryDTO, error) {
    rows, err := r.q.ListOrderSummariesByCustomer(ctx, db.ListOrderSummariesByCustomerParams{
        CustomerID: customerID,
        Limit:      limit,
        Offset:     offset,
    })
    if err != nil {
        return nil, fmt.Errorf("list summaries customer=%s: %w", customerID, err)
    }
    result := make([]view.OrderSummaryDTO, 0, len(rows))
    for _, row := range rows {
        result = append(result, view.OrderSummaryDTO{
            OrderID:      row.OrderID,
            Status:       string(row.Status),
            TotalAmount:  row.TotalAmount,
            ItemCount:    int(row.ItemCount),
            CustomerName: row.CustomerName,
            CreatedAt:    row.CreatedAt,
        })
    }
    return result, nil
}

view.OrderSummaryDTO — struct в core/<bc>/dto/view/, не агрегат. Маппинг тривиальный: flat-строка → flat-DTO, никакого map-редьюсера. Query-handler открывает read-only транзакцию через pgx.TxOptions{AccessMode: pgx.ReadOnly} (см. Транзакции).

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

АнтипаттернПравилоЧто взамен
Цикл repo.FindByID(ctx, id) для каждого элемента спискаR-SQLC-NEST-X1JOIN или WHERE id = ANY($1::uuid[])
Загрузка полного агрегата для read-only экранаR-SQLC-NEST-X2<X>ViewRepository с read-DTO
Сборка агрегата из flat-строк прямо в методе репозиторияR-SQLC-MAP-2Отдельный файл order_mapper.go с функцией toOrdersWithItems
json_agg для глубоко вложенных объектовR-SQLC-NEST-3Два запроса + маппер с map[uuid.UUID]*Order
Итерация for id := range index без ordered []uuid.UUIDR-SQLC-NEST-2Фиксировать порядок через отдельный срез при первом появлении

Куда дальше

  • Repository pattern в sqlc (Go) — PostgresOrderRepository, интерфейс-порт, WithTx, маппер рядом с репозиторием.
  • Транзакции в sqlc (Go) — граница TX на Handler, WithTx(pgx.Tx), defer tx.Rollback, read-only транзакция.
  • jOOQ Style Guide → раздел 5. Multiset — Java-аналог этой темы: multiset() как встроенный nested-fetch без ручного маппера.