Опирается на правила:
R-SQLC-NEST-1…R-SQLC-NEST-4иR-SQLC-NEST-X1…R-SQLC-NEST-X2из sqlc Style Guide → раздел 7. Nested-fetch и связанные агрегаты.
Важно знать
- Загрузка агрегата с вложенными сущностями — одним SQL-запросом с JOIN, не циклом обращений к репозиторию.
- Flat-результат JOIN собирается в
map[uuid.UUID]*Orderвнутри маппера, не внутри репозитория.- Порядок элементов сохраняется через отдельный
[]uuid.UUID ordered—mapпорядок не гарантирует.json_agg/array_agg— альтернатива для плоских вложений; для сложных агрегатов предпочтительнее два запроса в одной транзакции.- Read-проекции (CQRS query-side) — в отдельном
<X>ViewRepository, возвращают read-DTO, не полный агрегат.N+1черезWHERE id = ANY($1)— допустим для вспомогательных коллекций, когда JOIN слишком дорог; избегать циклFindByIDper-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-X1 | JOIN или 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.UUID | R-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 без ручного маппера.