В Go-стеке UCP нет ORM в духе Hibernate или TypeORM, и это сознательно. Данные ведут через sqlc — генератор, который превращает обычный SQL в типобезопасный Go-код, — поверх драйвера pgx. Ты пишешь SQL (а не учишь язык запросов ORM), а sqlc даёт типизированные функции, проверяемые компилятором.

Идея sqlc

Запросы пишут в .sql-файле с аннотацией имени и кардинальности; sqlc по схеме генерирует Go-функции с точными типами параметров и результата.

-- name: CreateProduct :one
INSERT INTO products (name, price)
VALUES ($1, $2)
RETURNING id, name, price;

-- name: GetProduct :one
SELECT id, name, price FROM products WHERE id = $1;

Из этого sqlc сгенерирует Queries со методами CreateProduct(ctx, arg CreateProductParams) (Product, error) и GetProduct(ctx, id int64) (Product, error). Опечатка в SQL или несоответствие типов всплывут на этапе генерации/компиляции, а не в рантайме. Это не ORM-магия: SQL остаётся явным, ты видишь точно, какой запрос уйдёт.

pgx как драйвер

Под sqlc лежит pgx — драйвер PostgreSQL. Пул соединений создают на старте (в main) и передают сгенерированному Queries.

pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
if err != nil {
    return fmt.Errorf("connect db: %w", err)
}
queries := db.New(pool)

ctx идёт в каждый запрос — так отмена и таймаут доходят до базы.

Репозиторий

Сгенерированный код оборачивают в репозиторий слоя UCP — чтобы домен зависел от своего интерфейса, а не от sqlc напрямую.

type ProductRepository struct {
    queries *db.Queries
}

func (r *ProductRepository) Save(ctx context.Context, cmd CreateProductCommand) (Product, error) {
    row, err := r.queries.CreateProduct(ctx, db.CreateProductParams{
        Name:  cmd.Name,
        Price: int32(cmd.Price),
    })
    if err != nil {
        return Product{}, fmt.Errorf("save product: %w", err)
    }
    return Product{ID: int(row.ID), Name: row.Name, Price: int(row.Price)}, nil
}

Транзакции

Граница транзакции в UCP — граница сценария: один Handler = одна транзакция. С pgx транзакцию открывают на пуле, а сгенерированный Queries переводят на неё через WithTx.

func (h *PlaceOrderHandler) Handle(ctx context.Context, cmd PlaceOrderCommand) error {
    tx, err := h.pool.Begin(ctx)
    if err != nil {
        return fmt.Errorf("begin: %w", err)
    }
    defer tx.Rollback(ctx)

    qtx := h.queries.WithTx(tx)
    if _, err := qtx.CreateOrder(ctx, /* ... */); err != nil {
        return fmt.Errorf("create order: %w", err)
    }
    if _, err := qtx.DecrementStock(ctx, /* ... */); err != nil {
        return fmt.Errorf("decrement stock: %w", err)
    }
    return tx.Commit(ctx)
}

defer tx.Rollback(ctx) безопасен: после успешного Commit откат уже ничего не делает, а на любом раннем return он откатит транзакцию. Решение «зафиксировать или откатить» принимает Handler сценария — это та же дисциплина, что @Transactional на уровне сервиса в Spring-биндинге.

Миграции

Схему версионируют миграциями (goose, migrate или аналог): они лежат в репозитории, прогоняются при выкате, обратимы. sqlc читает ту же схему, чтобы генерировать код, — поэтому схема и запросы всегда согласованы. Правило неизменно: схема едет миграциями, а не правится на проде руками.

Где это в UCP

Доступ к данным — в репозитории поверх sqlc, транзакция принадлежит Handler-у, ctx идёт сквозь всё — слои те же, что в любом биндинге UCP, только запросы типобезопасны и явны. Там, где ORM в Spring генерирует SQL за тебя, sqlc делает обратное: ты пишешь SQL, он даёт типобезопасный Go. Для продукт-инженера это означает полный контроль над тем, что уходит в базу, без сюрпризов ленивой загрузки и N+1 — и тестируется это на реальной базе через testcontainers.