Опирается на правила:
R-SQLC-MAP-1,R-SQLC-MAP-2,R-SQLC-MAP-3,R-SQLC-MAP-X1,R-SQLC-MAP-X2,R-SQLC-NEST-1…R-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.go — package 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-X1 | JOIN + 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-маппера; архитектурные решения те же, инструмент другой.