Опирается на правила:
R-SQLC-REPO-1…R-SQLC-REPO-4,R-SQLC-REPO-X1…R-SQLC-REPO-X3,R-SQLC-MAP-1…R-SQLC-MAP-3,R-SQLC-ERR-1…R-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.ErrNoRows | R-SQLC-ERR-X1 | return nil, &order.NotFoundError{ID: id} |
| Логирование pgx-ошибки в репозитории + проброс вверх | R-SQLC-ERR-X2 | Только fmt.Errorf("...: %w", err), лог — на edge |
reflect / json.Marshal для маппинга sqlc → domain | R-SQLC-MAP-X1 | Явные функции toDomain / toInsertParams |
N+1: цикл repo.FindByID по списку ID | R-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.