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

GO КАК ФУНДАМЕНТ Персистентность

Обязательно

Персистентность в Go: sqlc и pgx

Go

SQL как контракт, генерация типов, pgx, репозиторий, транзакции и миграции схемы.

1. SQL остаётся понятным контрактом

1 / 5

Запрос пишется напрямую, sqlc генерирует типизированные вызовы. Это уменьшает ручной маппинг и ошибки полей.

  • SQL и схема - исходные источники правды.
  • Генератор создаёт параметры и результаты.
  • Запрос остаётся доступным для EXPLAIN и ревью.
schema + SQLsqlctyped repoЗапрос читается, маппинг генерируется

2. pgx соединяет приложение с PostgreSQL

2 / 5

Пул соединений принимает контекст, SQL и параметры. Репозиторий вызывает сгенерированные методы или pgx напрямую.

  • Пул создаётся в main и закрывается при остановке.
  • QueryRow для одной строки, Query для списка.
  • Параметры передаются отдельно, не конкатенацией.
repositorypgxpoolPostgresctx + SQL + args → rows

3. Репозиторий скрывает детали хранения

3 / 5

Домен работает с методами хранилища, а не SQL-строками. Состояние соединения и маппинг остаются внутри репозитория.

  • Метод называется по операции домена.
  • Интерфейс упрощает подмену в тестах.
  • Ошибки хранения сохраняют причину через %w.
domainUserStoreSQLБизнес-операции, не детали БД

4. Транзакция сохраняет целостность

4 / 5

Несколько записей выполняются атомарно. Ошибка вызывает rollback, успех - commit.

  • Транзакция получает тот же контекст.
  • Rollback делается через defer.
  • После commit новые операции используют соединение отдельно.
BeginTxwrite Awrite BcommitrollbackВсё или ничего

5. Миграции меняют схему управляемо

5 / 5

Схема версионируется отдельными миграциями. Приложение не изменяет продакшн-структуру неявно при старте.

  • Миграции идут в одном направлении и ревьюируются.
  • sqlc.yaml связывает схему и запросы.
  • NULL и типы pgx различаются явно.
v1v2v3DBВерсионированная схема, типизированный доступ

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

В большинстве языков для работы с базой данных берут ORM — библиотеку, которая умеет сама генерировать SQL и отображать строки на объекты. В Go другой подход: здесь принято писать SQL руками и работать с результатами явно. Это чуть больше кода поначалу, но зато вы всегда знаете, какой запрос уходит в базу.

Чтобы убрать рутину без потери контроля, используют sqlc — инструмент, который читает ваши SQL-файлы и генерирует из них типобезопасный Go-код. Снизу работает pgx — быстрый и полноценный драйвер для PostgreSQL.

Зачем вообще sqlc, если можно просто написать SQL

Когда пишешь SQL напрямую через database/sql, приходится:

  • передавать параметры как interface{} без проверки типов;
  • вручную считывать строки через rows.Scan(&col1, &col2, ...) с риском перепутать порядок;
  • обнаруживать ошибки только в работающем приложении, а не при сборке.

sqlc решает эти проблемы. Вы пишете SQL как обычно, добавляете аннотацию с именем функции — и получаете готовые Go-функции с правильными типами. Ошибка в SQL или несоответствие типов обнаружится при генерации, а не в рантайме.

А где GORM

Самая популярная альтернатива это ORM: GORM описывает таблицы структурами с тегами, умеет AutoMigrate, связи через Preload и хуки, и для простого CRUD даёт меньше кода. Плата за это та же, что у любого ORM: SQL генерируется внутри, и запрос, который вы не видели, легко превращается в N+1 или в выборку всей таблицы. Между ними стоят sqlx, тонкая надстройка над database/sql со сканированием строк в структуры, и ent, где схему описывают кодом, а типизированный клиент генерируется. В этом курсе выбран sqlc, потому что SQL остаётся на виду, типы проверяются при сборке, а объяснять план запроса проще, когда запрос написан руками. Честный ответ на собеседовании звучит так: GORM ускоряет старт, sqlc дешевле в эксплуатации, и выбор зависит от того, сколько в сервисе нетривиальных запросов.

Как выглядит 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-файлы схемы, что и инструмент миграций, и проверяет каждый запрос против неё: обращение к несуществующей колонке, лишний или пропущенный параметр, неверный тип — ошибка на этапе sqlc generate, а не в проде. Обратное он не гарантирует: добавленная в схему колонка запросы не ломает, и дописать её в SELECT и INSERT — по-прежнему ваша работа.

Главное правило: схема меняется только через миграции, никогда руками напрямую в базе.

Дополнительно: при первом чтении можно пропустить

Глубже: sqlc.yaml, NULL и типы pgxрасширенное

Генерацию настраивает файл sqlc.yaml: где лежит схема, где запросы, куда класть код и через какой драйвер работать.

version: "2"
sql:
  - engine: "postgresql"
    schema: "migrations"
    queries: "queries"
    gen:
      go:
        package: "db"
        out: "internal/db"
        sql_package: "pgx/v5"

sql_package: pgx/v5 важен: без него sqlc сгенерирует код под database/sql, и подключить к нему пул pgxpool напрямую не получится.

Вторая неожиданность — NULL. Колонка без NOT NULL в Go становится не string, а pgtype.Text с полями String и Valid; писать row.Description.String и проверять Valid быстро надоедает. Лечат это на стороне схемы: почти всякую колонку можно объявить NOT NULL DEFAULT '', и тогда в Go придёт обычная строка. Там, где NULL по смыслу нужен (дата закрытия, необязательный внешний ключ), pgtype оставляют осознанно или включают в sqlc.yaml опцию emit_pointers_for_null_types, чтобы получать *string.

Из мелочей, которые сэкономят час: аннотация :execrows возвращает число затронутых строк (для UPDATE ... WHERE id = $1 это способ отличить «обновили» от «такого id нет»), а sqlc vet прогоняет запросы через EXPLAIN на живой базе и ловит то, чего не видно по схеме.

Коротко

  • sqlc читает ваши SQL-файлы и генерирует типобезопасные Go-функции — никакого ручного Scan и interface{}.
  • pgx и pgxpool — быстрый драйвер PostgreSQL с пулом соединений; ctx идёт в каждый запрос.
  • Сгенерированный код оборачивают в репозиторий, чтобы бизнес-логика не зависела от деталей SQL.
  • Транзакции открываются через pool.Begin, запросы переключаются на транзакцию через WithTx; defer tx.Rollback — страховка от незакрытой транзакции.
  • Границу транзакции держит обработчик сценария, а не репозиторий.
  • Миграции (goose или golang-migrate) версионируют схему; sqlc читает ту же схему и всегда согласован с ней.
  • sqlc.yaml с sql_package: pgx/v5; колонка без NOT NULL приходит как pgtype.Text, поэтому схему по возможности объявляют NOT NULL DEFAULT; :execrows отличает «обновили» от «нет такой строки».
  • GORM, sqlx и ent это другие ответы на тот же вопрос: ORM прячет SQL и ускоряет старт, sqlc держит SQL на виду и проверяет типы при сборке.

Что пощупать

Репозиторий каталога в практикуме remodov/marketplace-system-go без ORM и без генерации: SQL строками через pgx, деньги через numeric и decimal, обновление с проверкой version, транзакция как функция WithTx, строка под FOR UPDATE; схема накатывается миграциями goose при старте.

Код: repository.go, миграции.

Сделаем сами

Ветка step-05-stock-and-locking.

Что почитать дальше