---
title: "Паттерн «с транзакцией»: функция-обёртка"
description: "Паттерн «с транзакцией» в pgx: pgx.BeginFunc и свой WithTx, почему defer Rollback не забывается, интерфейс Querier для репозиториев, savepoints. Примеры кода."
course: "PostgreSQL в Go: драйвер pgx с нуля до продакшена"
url: "https://golangmaster.ru/courses/pgx/lessons/14-tx-pattern/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Паттерн «с транзакцией»: функция-обёртка

*Курс «PostgreSQL в Go: драйвер pgx с нуля до продакшена», урок 14 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/pgx/lessons/14-tx-pattern/*

Паттерн «с транзакцией» — это функция-обёртка, которая принимает колбэк и сама ведёт каркас: Begin, defer Rollback, Commit при успехе. В pgx она уже есть — pgx.BeginFunc, а при нужде в политике пишется свой WithTx за десять строк. Разберём обе версии, устройство внутри, передачу транзакции в репозитории и вложенные savepoints.

Каркас из тринадцатого урока — Begin, проверка ошибки, defer Rollback, работа, Commit — честный, но повторяющийся: на сервис с сорока операциями это сорок одинаковых четырёхстрочных прелюдий, и каждая четвёртая на ревью хромает: то defer забыт, то Commit не проверен, то Rollback зовётся только в одной ветке. Паттерн «с транзакцией» устраняет повторяемость конструкцией языка: операция описывается функцией, которую обёртка запускает внутри транзакции — открытие, страховка и финал живут в одном месте и не забываются никогда. В pgx обёртка готовая: pgx.BeginFunc принимает пул, соединение или другую транзакцию и колбэк вида func(tx pgx.Tx) error. Разберём сначала коробочную версию, затем её внутренности — там всего десять строк и одно важное правило про контекст, — потом свой WithTx, который нужен, когда к каркасу добавляется политика: метрики, лимит времени, повторные попытки. Дальше — то, вокруг чего паттерн и придуман: как правильно передавать транзакцию в репозитории, чтобы она не разваливалась на полпути, и что pgx умеет с вложенными транзакциями. Ключевое требование к уроку одно: после него вы не забудете Rollback — не потому что стали дисциплинированнее, а потому что забыть стало невозможно.

### pgx.BeginFunc: обёртка из коробки

Сигнатура: pgx.BeginFunc(ctx, db, fn) — где db это всё, у чего есть метод Begin(ctx) (pgx.Tx, error), а fn — func(tx pgx.Tx) error. На место db встаёт и *pgxpool.Pool из четвёртого урока, и *pgx.Conn, и сама транзакция — про последний случай ниже, он живее, чем кажется. Контракт BeginFunc короткий: открыть транзакцию, вызвать fn; вернула nil — Commit, вернула ошибку — Rollback, и именно её ошибку обёртка вернёт наверх. Обратите внимание, что ошибка запроса внутри fn обычно никуда специально не «отправляется» — вы сами возвращаете её из fn, и это делает поток ошибок прямым и читаемым: одна точка выхода, никаких флагов и состояний. Всё, что fn делает через tx.Exec и tx.Query, едет на коннект транзакции; пул внутрь не лезет — на этом построено живое место ниже. Отдельная деталь, которая сбивает с толку новичков: контекст в BeginFunc используется для управляющих команд BEGIN, COMMIT и ROLLBACK, но не ограничивает выполнение fn — если нужен дедлайн на всю операцию, его приносит сам ctx через WithTimeout, и о расстановке бюджетов говорит пятнадцатый урок. Пока — просто запомните: BeginFunc не добавляет таймаутов от себя.

### Перевод денег через BeginFunc

```go
func transfer(ctx context.Context, pool *pgxpool.Pool,
        fromID, toID int, amount int64) error {

        return pgx.BeginFunc(ctx, pool, func(tx pgx.Tx) error {
                _, err := tx.Exec(ctx,
                        "UPDATE accounts SET balance = balance - $1 WHERE id = $2",
                        amount, fromID)
                if err != nil {
                        return err
                }

                _, err = tx.Exec(ctx,
                        "UPDATE accounts SET balance = balance + $1 WHERE id = $2",
                        amount, toID)
                if err != nil {
                        return err
                }

                return nil
        })
}
```

Функция перевода свелась к возвращению pgx.BeginFunc: обёртка открывает транзакцию на пуле, запускает колбэк и сама выбирает Commit либо Rollback по ошибке колбэка. ErrTxClosed от безвредного отката тоже поглощается внутри. Ни одной строки про Begin и Rollback — и забывать их нечем.

### Что внутри BeginFunc: контракт ошибок

Обёртка умещается в десять строк, и их стоит понимать, потому что контракт проще показать, чем описать. После Begin ставится defer tx.Rollback() — тот же приём из тринадцатого урока; далее fn вызывается. Если fn вернула ошибку, обёртка делает Rollback ещё раз явно и возвращает вашу ошибку без изменений — то, что fn хотела сказать наверх, не обёрнуто и не съедено. Если fn вернула nil, выполняется Commit, а отложенный Rollback после него вернёт ErrTxClosed, который обёртка распознаёт через errors.Is и молча игнорирует — тот самый безопасный defer. Практическое следствие: собственная ошибка Commit доходит до вызывающего как есть — обёртка не глотает её; фильтр errors.Is игнорирует только ErrTxClosed, а после Commit или явного отката транзакция уже закрыта, так что отложенный Rollback в живом коде возвращает именно его. Вторая коробочная версия — pgx.BeginTxFunc(ctx, db, options, fn): то же самое, но с pgx.TxOptions, где задаются уровень изоляции pgx.Serializable или pgx.RepeatableRead, режим доступа pgx.ReadOnly и откладываемость; она пригодится для отчётов, которым нужен согласованный срез данных, — базовый уровень read committed из тринадцатого урока остаётся выбором по умолчанию.

### Свой WithTx: когда нужна своя обёртка

```go
type Store struct {
        pool *pgxpool.Pool
}

func (s *Store) WithTx(ctx context.Context,
        fn func(pgx.Tx) error) error {

        ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
        defer cancel()

        tx, err := s.pool.Begin(ctx)
        if err != nil {
                return err
        }
        defer func() {
                if rErr := tx.Rollback(ctx); rErr != nil &&
                        !errors.Is(rErr, pgx.ErrTxClosed) {
                        log.Printf("rollback: %v", rErr)
                }
        }()

        if err := fn(tx); err != nil {
                return err
        }
        return tx.Commit(ctx)
}
```

Свой WithTx повторяет контракт BeginFunc и добавляет политику: дедлайн на всю транзакцию через context.WithTimeout и логирование редкого сбоя отката. Заметьте порядок: таймер ставится до Begin, поэтому дедлайн накрывает и ожидание коннекта из пула, и все запросы колбэка. Каркас из тринадцатого урока никуда не делся — он переехал в одно место проекта, где его видит ревью, а не размазан по сорока операциям.

### Свой WithTx: когда нужна своя обёртка

Коробочная BeginFunc покрывает восемь случаев из десяти, и начинать стоит с неё. Своя обёртка оправдывается, когда у каркаса появляется политика. Первое — время: дедлайн на транзакцию целиком, как в примере выше, — у BeginFunc для этого нет крючка, он честно берёт ваш контекст как есть. Второе — наблюдаемость: метрика «сколько транзакций откатилось», лог медленных операций, трассировка — всё это пишется один раз в WithTx и работает для всех операций автоматически. Третье — типовые отступления от атомарности: компенсирующие действия при откате, события для шины после коммита. Отдельный соблазн — ретраи: обёртка вроде место для повторной попытки, но повторять можно только операцию без внешних побочных эффектов, готовую к тому, что первая попытка откатилась целиком; колбэк с письмом внутри ретраить нельзя. Четвёртое — тесты: WithTx на интерфейсе позволяет подставить фейковый store без базы — тема двадцать третьего урока. Где проходит граница разумного: обёртка не должна знать про бизнес-логику — как только внутри появились условия «если такая-то таблица, то другой уровень изоляции», это уже не каркас, а новое место для багов. Правило то же, что у middleware: обёртка делает механику, функция — смысл. И да, хранить WithTx стоит рядом с пулом: методы структуры Store получают его «бесплатно», без передачи лишних аргументов.

> **Живое место: p.Exec внутри колбэка.** Самая частая ошибка паттерна: внутри func(tx pgx.Tx) запрос сделан не через tx, а через захваченный по замыканию pool — p.Exec уходит на любой свободный коннект мимо транзакции и в откат не попадает. Всё выглядит рабочим: коммит зелёный, данные на месте, — но половина операции вне атомарности, и в тесте с падением на середине это выстрелит. Что сломалось бы: откат операции с уже записанным побочным эффектом. Лекарство дисциплинарное: колбэк принимает tx, и внутри него слово pool встречаться не должно; ревью ловит это одним поиском.

### Транзакция и репозитории: интерфейс Querier

Паттерн живёт или умирает на границе со слоем репозиториев. Первая наивная версия: методы репозитория принимают pgx.Tx — CreateOrder(ctx, tx, order). Работает, но связывает методы с транзакцией жёстко: тот же метод из кода без транзакции уже не вызвать, а в тестах придётся мокать весь pgx.Tx. Вторая версия — узкий интерфейс: type Querier interface { Exec(ctx, sql, args...) (...); Query(...); QueryRow(...) }. В pgx его реализуют и *pgxpool.Pool, и pgx.Tx — наборы методов у них совпадают намеренно, это проверено по исходникам. Репозиторий объявляет методы под Querier и не знает, приехал ли он с транзакцией: сервис при обычной операции передаёт пул, при составной — tx из BeginFunc. Бонус этой схемы — вложенность: у pgx.Tx есть и Begin, поэтому репозиторий, получивший tx, при нужде сам откроет savepoint, не ломая контракт. Нужен интерфейс из трёх методов или меньше? Сужайте под каждый репозиторий — вместо Querier с Exec может быть OrderWriter с одним InsertOrder; принцип тот же: функция просит ровно то, чем пользуется, и это остаётся честным при передаче транзакции. Спорная деталь, о которой спорят на ревью: передавать ли tx явно параметром или прятать в контекст. Явный параметр виден в сигнатуре и в трассировке; контекст короче, но превращает связь в невидимую — я за явный, пока команда не договорилась об обратном осознанно.

### Querier: один метод, два источника

```go
type Querier interface {
        Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
        Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
        QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}

func (r *OrderRepo) Create(ctx context.Context, q Querier,
        o Order) error {

        _, err := q.Exec(ctx,
                "INSERT INTO orders (user_id, total) VALUES ($1, $2)",
                o.UserID, o.Total)
        return err
}
```

Метод Create принимает Querier — интерфейс из трёх глаголов, который реализуют и пул, и pgx.Tx: сигнатуры совпадают до буквы, от CommandTag до Row. Сервис зовёт его двояко: repo.Create(ctx, pool, order) в обычном коде и repo.Create(ctx, tx, order) внутри BeginFunc — репозиторий не меняется, а транзакция доехала до места назначения; тестовая подмена реализации ложится на тот же узкий интерфейс.

### Проверка: контракт BeginFunc

**Вопрос:** Колбэк внутри pgx.BeginFunc вернул ошибку. Что сделает обёртка?

- ✅ Откатит транзакцию и вернёт наверх именно ошибку колбэка
- Откатит транзакцию и вернёт ErrTxClosed
- Оставит транзакцию открытой для ручного разбора
- Сделает Commit: ошибки колбэка не влияют на финал

Контракт BeginFunc: ошибка колбэка означает откат, и наверх уходит именно она — обёртка не оборачивает и не заменяет её. ErrTxClosed появляется в другой роли: как безвредный ответ отложенного Rollback после успешного Commit, и обёртка его игнорирует.

### Вложенные транзакции: savepoints

Внутри fn транзакция — тоже подходящее db для BeginFunc: pgx.BeginFunc(ctx, tx, fn2) сработает, но создаст не настоящую вторую транзакцию, а псевдо — через savepoint. Драйвер зовёт tx.Begin, который исполняет SAVEPOINT sp_N с растущим номером; Commit псевдотранзакции — это RELEASE SAVEPOINT, Rollback — ROLLBACK TO SAVEPOINT. Поведение для кода то же: ошибка внутреннего колбэка откатывает его часть и возвращается наверх, внешняя транзакция жива и может продолжиться. Это верно и для настоящей SQL-ошибки: после неё транзакция сидит в аварийном состоянии из тринадцатого урока, и снимает его именно ROLLBACK TO SAVEPOINT — в Postgres это единственный способ продолжить транзакцию после реального сбоя, не стирая уже годную работу; полный Rollback честнее, но стирает всё подряд. Классическое применение — батч независимых шагов: третий из пяти упал, откатили его, четыре остальных доехали; деталей больше, чем пользы, но как дешёвый частичный откат в групповых операциях savepoints честно работают. Есть и цена: каждый savepoint — лишняя поездка команды на сервер, и злоупотреблять вложенностью на горячем пути не стоит. Одна оговорка к истории с аварийным состоянием: продолжить можно только работу после отката к savepoint, а сама ошибка всё равно обязана доехать до вызывающего — молча проглотить её и делать вид, что ничего не случилось, — путь к данным, о которых никто не спрашивал.

### Что останется в базе?

```go
err := pgx.BeginFunc(ctx, pool, func(tx pgx.Tx) error {
    if err := repo.Create(ctx, tx, order); err != nil {
        return err
    }
    return pgx.BeginFunc(ctx, tx, func(sp pgx.Tx) error {
        if err := repo.Create(ctx, sp, auditRow); err != nil {
            return err
        }
        return errors.New("audit failed validation")
    })
})

fmt.Println(err != nil)
```

**Что выведет программа?**

- ✅ true — и order, и auditRow откатились
- true — order записан, auditRow откатился savepoint-ом
- false — обе записи на месте
- true — и order, и auditRow записаны

Внутренний BeginFunc на tx открыл savepoint: его Rollback стёр auditRow и вернул ошибку наверх; внешний колбэк пробросил её дальше — и внешняя обёртка откатила уже всё, включая order. Итог: err != nil — true, а в базе пусто: обе записи стёрты, внешняя и внутренняя. Если бы внешний колбэк обработал ошибку сам и вернул nil, внешний Commit спас бы order — вот ради этой развилки savepoints и ставят.

### Три способа провести транзакцию

| Способ | Кто держит каркас | Когда выбирать |
| --- | --- | --- |
| Руками: Begin, defer Rollback, Commit | Каждая операция | Разовый случай, изучение механики |
| pgx.BeginFunc / BeginTxFunc | Библиотека | Дефолт: каркас без политики |
| Свой WithTx | Ваш проект | Дедлайны, метрики, ретраи, тесты |

### Правила обёртки одним списком

- Начинайте с pgx.BeginFunc: контракт уже продуман и проверен
- fn возвращает nil только когда всё действительно готово к Commit
- Внутри колбэка — только tx; пул в замыкании — баг, а не стиль
- Своя WithTx — ради политики: дедлайн, метрики, ретраи
- Репозитории — под узкий интерфейс Querier, а не под pgx.Tx
- tx передаётся явно параметром, контекст для этого — скрытая связь
- Вложенный BeginFunc на tx — savepoint: частичный откат, не новая транзакция
- BeginTxFunc — когда нужен уровень изоляции или pgx.ReadOnly

### Проверка: Querier

**Вопрос:** Почему *pgxpool.Pool можно передать в метод, ожидающий интерфейс Querier из урока?

- Пул реализует все методы pgx.Tx целиком
- ✅ У пула есть Exec, Query и QueryRow с теми же сигнатурами
- Querier — алиас типа *pgxpool.Pool
- Пул автоматически конвертируется в транзакцию

Интерфейс Querier объявляет ровно три глагола, и и пул, и pgx.Tx реализуют их с одинаковыми сигнатурами — поэтому метод репозитория принимает и то, и другое. Полный набор методов pgx.Tx для этого не нужен: интерфейс намеренно узкий.

### Соберите вызов обёртки

Восстановите сигнатуру BeginFunc и безопасный откат в своей обёртке.

```go
return pgx.___(ctx, pool, func(tx pgx.___) error {
        _, err := tx.Exec(ctx, "UPDATE accounts SET balance = $1 WHERE id = $2", 900, 1)
        if err != nil {
                return err
        }
        return nil
})

if rErr := tx.___(ctx); rErr != nil &&
        !errors.Is(rErr, pgx.ErrTxClosed) {
        log.Printf("rollback: %v", rErr)
}
```

Подсказка: Функция называется BeginFunc, колбэк получает pgx.Tx, а страховку делает Rollback с проверкой ErrTxClosed.

Правильные ответы в пропусках: `BeginFunc`, `Tx`, `Rollback`

### Соберите WithTx

Расставьте строки собственной обёртки в правильном порядке.

Правильный порядок строк:

1. `func (s *Store) WithTx(ctx context.Context, fn func(pgx.Tx) error) error {`
2. `  tx, err := s.pool.Begin(ctx)`
3. `  if err != nil { return err }`
4. `  defer func() {`
5. `    if rErr := tx.Rollback(ctx); rErr != nil &&`
6. `      !errors.Is(rErr, pgx.ErrTxClosed) {`
7. `      log.Printf("rollback: %v", rErr)`
8. `    }`
9. `  }()`
10. `  if err := fn(tx); err != nil { return err }`
11. `  return tx.Commit(ctx)`
12. `}`

### Карточки: паттерн «с транзакцией»

- **pgx.BeginFunc** — Обёртка pgx: Begin, fn, Rollback при ошибке, Commit при успехе. db — всё с методом Begin(ctx) (pgx.Tx, error).
- **Контракт ошибок BeginFunc** — Ошибка колбэка — откат и возврат именно её наверх; ErrTxClosed после успешного Commit обёртка игнорирует.
- **pgx.BeginTxFunc** — Та же обёртка с pgx.TxOptions: уровень изоляции, pgx.ReadOnly, откладываемость — для отчётов и согласованных срезов.
- **Querier** — Узкий интерфейс из Exec, Query, QueryRow, который реализуют и пул, и pgx.Tx: репозиторий не знает, откуда приехал.
- **Savepoint-вложенность** — BeginFunc на tx создаёт SAVEPOINT sp_N: Commit — RELEASE, Rollback — ROLLBACK TO. Частичный откат живой внешней транзакции.
- **p.Exec внутри колбэка** — Запрос уходит на другой коннект мимо транзакции и не откатывается. Классический баг паттерна: внутри fn — только tx.

> **Где паттерн встречается в других обёртках.** С тем же каркасом вы столкнётесь в sqlc — его сгенерированный Queries принимает Querier-подобный DBTX, в ent и GORM свой tx-хелпер устроен одинаково: транзакция — колбэк. Понимая BeginFunc, вы читаете все эти обёртки без документации.

Живые проверки — локально: песочница урока покажет тематическую программу на чистом Go, а WithTx гоняйте на Postgres из первого урока — таблица accounts из тринадцатого урока подойдёт. Дальше по курсу: в [пятнадцатом](/courses/pgx/lessons/15-kontekst-tajmauty/) — контекст и таймауты, включая то, что BeginFunc не добавляет дедлайнов сам; в [восемнадцатом](/courses/pgx/lessons/18-batch-zaprosy/) — Batch внутри транзакции, когда шагов становится десятки; в [двадцать третьем](/courses/pgx/lessons/23-testy-s-testcontainers/) — тесты обёртки с настоящей базой в Docker. Домашнее задание в конце урока сводит всё: переписать перевод денег на BeginFunc, завернуть в свой WithTx с метрикой и уронить середину — база должна остаться нетронутой.

## Коротко: что запомнить

- pgx.BeginFunc(ctx, db, fn) ведёт каркас сам: Begin, Rollback при ошибке fn, Commit при nil.
- db — всё с методом Begin(ctx) (pgx.Tx, error): пул, соединение, другая транзакция.
- Контекст обёртки служит BEGIN, COMMIT и ROLLBACK, но не ставит дедлайн на fn.
- Свой WithTx оправдан политикой: дедлайн, метрики, ретраи, тестируемость.
- Репозитории принимают узкий Querier — его реализуют и пул, и pgx.Tx.
- tx передаётся явно; пул внутри колбэка уводит запросы мимо транзакции.
- Вложенный BeginFunc на tx — savepoint: частичный откат живой внешней транзакции.
- Ошибка самого Commit доходит наверх как есть — обёртка глотает только ErrTxClosed отложенного отката.

## Домашнее задание

Перепишите перевод денег из тринадцатого урока на pgx.BeginFunc, затем на собственный WithTx с дедлайном и счётчиком откатов. Добавьте OrderRepo с методом Create под Querier и вызовите его дважды: с пулом и внутри транзакции. Уроните операцию посередине — ошибкой второго запроса — и убедитесь, что ни одна строка не записана. В конце заверните внутренний шаг в BeginFunc на tx с намеренной ошибкой и проверьте, что внешняя операция может обработать отказ шага и продолжиться.

## Вопросы и ответы по уроку

### Почему не прятать tx в context.Context?

Так делают, и это работает, но связь становится невидимой: сигнатура репозитория не говорит, что метод ведёт себя иначе внутри транзакции, а забытый set/get ключа ловится только тестами. Явный параметр дороже в написании на одну строку и дешевле в отладке; правило простое — транзакция влияет на поведение, значит, она в сигнатуре.

### BeginFunc делает COMMIT в отдельной горутине или ждёт?

Ждёт: и Rollback, и Commit выполняются синхронно в той же горутине, что и вызов обёртки, — это видно в исходниках: beginFuncExec завершается возвратом tx.Commit(ctx). Асинхронность здесь была бы вредна: вызывающий обязан знать результат коммита до продолжения работы.

### Можно вернуть из fn свою ошибку-статус для HTTP?

Да, и это нормальный путь: обёртка откатит транзакцию и вернёт вашу ошибку наверх без изменений — навесьте на неё тип или код, как удобно слою транспорта. Единственное предостережение: sentinel-ошибки вроде «нет средств» не должны маскироваться под ошибки соединения — их разворачивают через errors.Is, как в шестнадцатом уроке. Ролей у обёртки ровно две — механика и прозрачность ошибок; как только появляется третья, это повод вынести её из колбэка.
