← назад к разделу

В большинстве языков для работы с базой данных берут 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.