В большинстве языков для работы с базой данных берут ORM — библиотеку, которая умеет сама генерировать SQL и отображать строки на объекты. В Go другой подход: здесь принято писать SQL руками и работать с результатами явно. Это чуть больше кода поначалу, но зато вы всегда знаете, какой запрос уходит в базу.
Чтобы убрать рутину без потери контроля, используют sqlc — инструмент, который читает ваши SQL-файлы и генерирует из них типобезопасный Go-код. Снизу работает pgx — быстрый и полноценный драйвер для PostgreSQL.
Зачем вообще sqlc, если можно просто написать SQL
Когда пишешь SQL напрямую через database/sql, приходится:
- передавать параметры как
interface{}без проверки типов; - вручную считывать строки через
rows.Scan(&col1, &col2, ...)с риском перепутать порядок; - обнаруживать ошибки только в работающем приложении, а не при сборке.
sqlc решает эти проблемы. Вы пишете SQL как обычно, добавляете аннотацию с именем функции — и получаете готовые Go-функции с правильными типами. Ошибка в SQL или несоответствие типов обнаружится при генерации, а не в рантайме.
Как выглядит sqlc на практике
Запросы живут в .sql-файлах. Каждому запросу даётся имя и указывается, сколько строк он возвращает:
-- 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;
-- name: ListProducts :many
SELECT id, name, price FROM products ORDER BY name;
Аннотации :one, :many, :exec говорят sqlc, что сгенерировать: функцию, возвращающую одну строку, несколько строк или просто выполняющую запрос.
После запуска sqlc generate в коде появятся типизированные функции:
func (q *Queries) CreateProduct(ctx context.Context, arg CreateProductParams) (Product, error)
func (q *Queries) GetProduct(ctx context.Context, id int64) (Product, error)
func (q *Queries) ListProducts(ctx context.Context) ([]Product, error)
Никакого ручного Scan, никаких interface{}. Компилятор проверит типы.
pgx: соединение с базой
Под sqlc работает pgx — драйвер PostgreSQL. Пул соединений создаётся один раз при старте приложения:
pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
if err != nil {
return fmt.Errorf("connect db: %w", err)
}
queries := db.New(pool)
pgxpool держит несколько соединений открытыми и раздаёт их по мере надобности. Каждый запрос принимает ctx — это значит, что отмена запроса или таймаут доходят до базы, а не только до Go-кода.
Репозиторий: прослойка между логикой и базой
Сгенерированный код sqlc не используют напрямую в бизнес-логике. Вместо этого его оборачивают в репозиторий — структуру, которая знает, как сохранять и загружать конкретные объекты.
Это разделение полезно: бизнес-логика зависит от интерфейса репозитория, а не от деталей SQL. Если завтра изменится схема или запрос — правки будут только в репозитории.
type ProductRepository struct {
queries *db.Queries
}
func (r *ProductRepository) Save(ctx context.Context, name string, price int) (Product, error) {
row, err := r.queries.CreateProduct(ctx, db.CreateProductParams{
Name: name,
Price: int32(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
}
Транзакции
Транзакция нужна, когда несколько операций с базой должны выполниться вместе или не выполниться вовсе. Например, создать заказ и уменьшить остаток на складе — это одна атомарная операция.
С 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 tx: %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, транзакция автоматически откатится. После успешного Commit вызов Rollback ничего не делает — это безопасно.
Всю транзакцию держит один обработчик (Handler) — та самая функция, которая реализует один сценарий. Не репозиторий, не контроллер, а именно он. Это позволяет видеть границы транзакции явно, не теряя их где-то внутри вложенных вызовов.
Миграции схемы
Структура таблиц в базе должна быть под контролем так же, как код. Для этого используют миграции — набор SQL-файлов, каждый из которых делает конкретное изменение схемы (добавить таблицу, добавить колонку, создать индекс).
Популярные инструменты для миграций в Go: goose и golang-migrate. Принцип у обоих одинаков:
- каждая миграция пронумерована и хранится в репозитории;
- прогоняются при выкате нового кода;
- изменения обратимы через
down-миграцию.
sqlc читает те же SQL-файлы схемы, что и инструмент миграций. Поэтому генерируемый Go-код всегда соответствует реальной схеме — если вы добавили колонку в схему, но забыли обновить запросы, sqlc покажет расхождение при следующей генерации.
Главное правило: схема меняется только через миграции, никогда руками напрямую в базе.
Коротко
- sqlc читает ваши SQL-файлы и генерирует типобезопасные Go-функции — никакого ручного
Scanиinterface{}. - pgx и pgxpool — быстрый драйвер PostgreSQL с пулом соединений;
ctxидёт в каждый запрос. - Сгенерированный код оборачивают в репозиторий, чтобы бизнес-логика не зависела от деталей SQL.
- Транзакции открываются через
pool.Begin, запросы переключаются на транзакцию черезWithTx;defer tx.Rollback— страховка от незакрытой транзакции. - Границу транзакции держит обработчик сценария, а не репозиторий.
- Миграции (goose или golang-migrate) версионируют схему; sqlc читает ту же схему и всегда согласован с ней.
Что почитать дальше
- Структура и проводка зависимостей — как собрать приложение и передать пул соединений.
- Context и отмена — как
ctxдоходит до запросов в базу. - Тестирование — как тестировать репозитории на реальной базе через testcontainers.