В примерах используется GORM, но идея не привязана к ORM. Аналогичный менеджер можно написать поверх database/sql, pgx или другого способа работы с PostgreSQL. Меняется инфраструктурная реализация, а use case по-прежнему зависит только от небольшого интерфейса.
Содержание
- Где возникает проблема
- Реализация целиком
- Кто должен управлять транзакцией
- Как хранить транзакцию в context
- Почему сначала нужен ExtractDB
- Порядок операций в InTransaction
- Как подключаются репозитории
- Граница транзакции в use case
- Что делать с обязательной транзакцией
- Как context управляет жизненным циклом
- Пессимистическая блокировка
- Ограничения подхода
- Unit of Work как явная альтернатива
- Когда использовать готовый менеджер
- Как тестировать
- Практические правила
- Материалы
Где возникает проблема
Представим оформление заказа. Сценарий должен:
- Создать заказ.
- Добавить его позиции.
- Изменить состояние платежа.
Если третья операция завершится ошибкой, первые две также должны откатиться. Значит, все репозитории должны использовать одну транзакцию.
Без общей границы код легко превращается в три независимые записи:
order, err := uc.orders.Create(ctx, input.Order)
if err != nil {
return err
}
if err := uc.items.Create(ctx, order.ID, input.Items); err != nil {
return err
}
return uc.payments.MarkAuthorized(ctx, input.PaymentID)
Каждый вызов может завершиться успешно сам по себе. Ошибка последнего репозитория уже не отменит изменения предыдущих.
Передавать *gorm.DB во все методы тоже можно:
orders.Create(ctx, tx, input.Order)
items.Create(ctx, tx, order.ID, input.Items)
payments.MarkAuthorized(ctx, tx, input.PaymentID)
Но тогда use case знает о GORM, а каждая сигнатура репозитория смешивает бизнес-аргументы с инфраструктурным объектом. При переходе на database/sql или pgx придётся менять код выше слоя хранения.
Реализация целиком
Весь механизм умещается в один пакет. Ниже он приведён полностью, а дальше по статье разобран по частям.
// internal/pkg/transaction/transaction.go
package transaction
import (
"context"
"errors"
"gorm.io/gorm"
)
type Manager interface {
InTransaction(
ctx context.Context,
fn func(context.Context) error,
) error
}
type txContextKey struct{}
var txKey txContextKey
var ErrTransactionNotFound = errors.New("transaction not found")
type GormManager struct {
db *gorm.DB
}
func NewGormManager(db *gorm.DB) *GormManager {
return &GormManager{db: db}
}
func (m *GormManager) InTransaction(
ctx context.Context,
fn func(context.Context) error,
) error {
if err := ctx.Err(); err != nil {
return err
}
db := ExtractDB(ctx, m.db).WithContext(ctx)
return db.Transaction(func(tx *gorm.DB) error {
txCtx := context.WithValue(ctx, txKey, tx)
return fn(txCtx)
})
}
func ExtractDB(ctx context.Context, defaultDB *gorm.DB) *gorm.DB {
if tx, ok := ctx.Value(txKey).(*gorm.DB); ok {
return tx
}
return defaultDB
}
func ExtractTx(ctx context.Context) (*gorm.DB, error) {
if tx, ok := ctx.Value(txKey).(*gorm.DB); ok {
return tx, nil
}
return nil, ErrTransactionNotFound
}
Репозиторий выбирает подключение заново в каждом методе:
type OrderRepository struct {
db *gorm.DB
}
func (r *OrderRepository) dbFromContext(ctx context.Context) *gorm.DB {
return transaction.ExtractDB(ctx, r.db).WithContext(ctx)
}
func (r *OrderRepository) Create(
ctx context.Context,
order Order,
) (Order, error) {
if err := r.dbFromContext(ctx).Create(&order).Error; err != nil {
return Order{}, fmt.Errorf("create order: %w", err)
}
return order, nil
}
Use case открывает границу и передаёт вниз txCtx:
func (uc *CheckoutUseCase) Execute(
ctx context.Context,
input CheckoutInput,
) error {
return uc.tm.InTransaction(ctx, func(txCtx context.Context) error {
order, err := uc.orders.Create(txCtx, input.Order)
if err != nil {
return fmt.Errorf("create order: %w", err)
}
if err := uc.items.Create(txCtx, order.ID, input.Items); err != nil {
return fmt.Errorf("create order items: %w", err)
}
if err := uc.payments.MarkAuthorized(
txCtx,
input.PaymentID,
); err != nil {
return fmt.Errorf("authorize payment: %w", err)
}
return nil
})
}
Кода мало, но сломать его можно в нескольких местах, и молча. Дальше разобрано, почему ключ контекста объявлен собственным типом, зачем ExtractDB стоит до Transaction и что происходит, если в репозиторий уедет исходный ctx.
Кто должен управлять транзакцией
Граница транзакции должна совпадать с бизнес-операцией. Только use case знает, какие действия обязаны завершиться вместе, поэтому именно он вызывает менеджер транзакций.
При этом use case не обязан знать, как менеджер выполняет BEGIN, COMMIT и ROLLBACK. Из всего пакета ему виден только интерфейс Manager с единственным методом. Реализацию подставляет composition root, поэтому замена GORM на pgx до use case не доходит.
Callback получает производный контекст. Все вызовы репозиториев внутри него должны использовать именно этот контекст.
Менеджер отвечает за границу транзакции, а репозиторий выполняет запросы на правильном подключении. Репозиторий знает о механизме ExtractDB, но не открывает транзакцию и не решает, когда её фиксировать.
Как хранить транзакцию в context
Ключ в листинге объявлен неэкспортируемым типом txContextKey, хотя обычная строка выглядела бы короче:
context.WithValue(ctx, "tx", tx)
Каждый Context образует цепочку из текущего значения и родителей. Value ищет ключ от текущего контекста вверх по этой цепочке.
Коллизия возникает, если два пакета используют одинаковый строковый ключ в одной цепочке:
request context
└── middleware A: "tx" = firstValue
└── middleware B: "tx" = secondValue
└── Value("tx") вернёт secondValue
Собственный тип делает ключ другого пакета неравным вашему, даже если оба визуально называются tx. Это соответствует рекомендации из документации context.WithValue.
Читают это значение две функции пакета, и ведут они себя по-разному:
ExtractDBвозвращает текущую транзакцию или основное подключение;ExtractTxтребует активную транзакцию и возвращает ошибку, если её нет.
Почему сначала нужен ExtractDB
Перед новым Transaction менеджер выбирает текущее подключение:
db := ExtractDB(ctx, m.db)
Эта строка стоит до вызова Transaction, а не после него. Причина становится понятна на вложенном сценарии.
Допустим, оплату вынесли в отдельный PaymentUseCase. Он умеет работать сам по себе, поэтому тоже открывает границу через InTransaction, а checkout вызывает вместо репозитория платежей соседний сценарий:
func (uc *CheckoutUseCase) Execute(
ctx context.Context,
input CheckoutInput,
) error {
return uc.tm.InTransaction(ctx, func(txCtx context.Context) error {
if _, err := uc.orders.Create(txCtx, input.Order); err != nil {
return err
}
return uc.payment.Execute(txCtx, input.PaymentID)
})
}
Если менеджер всегда открывает транзакцию через корневой m.db, получится две независимые транзакции:
func (m *GormManager) InTransaction(
ctx context.Context,
fn func(context.Context) error,
) error {
return m.db.Transaction(func(tx *gorm.DB) error {
return fn(context.WithValue(ctx, txKey, tx))
})
}
Фактическая структура будет такой:
m.db
├── tx1: CheckoutUseCase
└── tx2: PaymentUseCase
Внутренний callback получит tx2, и репозитории корректно извлекут её из контекста. Но tx2 никак не связана с tx1. Если внутренняя транзакция выполнит COMMIT, а внешняя затем выполнит ROLLBACK, изменения платежа останутся в базе.
Отдельная транзакция создаёт и менее очевидные ошибки. Она не видит незакоммиченные данные внешней транзакции, потому что PostgreSQL не допускает dirty read. Если внешняя транзакция уже заблокировала нужную строку, внутренняя может ждать её освобождения до дедлайна.
ExtractDB меняет выбор получателя вызова:
транзакции в context нет → m.db.Transaction → обычная транзакция
транзакция в context есть → tx1.Transaction → вложенная транзакция
GORM создаёт вложенную транзакцию через savepoint только тогда, когда Transaction вызывается на текущем tx. Такой вызов показан в официальной документации GORM.
Теперь успешное завершение внутреннего callback не фиксирует данные независимо от внешней транзакции. Итоговый COMMIT или ROLLBACK по-прежнему принадлежит внешней границе.
Порядок операций в InTransaction
Тело InTransaction состоит из четырёх строк, и переставить их местами нельзя: каждая следующая работает с тем, что вернула предыдущая.
ExtractDBвыбирает основное подключение или уже открытую транзакцию.WithContextпередаёт GORM отмену и дедлайн операции.Transactionоткрывает обычную транзакцию либо savepoint.context.WithValueпередаёт полученныйtxвсем репозиториям ниже по стеку.
Если проект принципиально запрещает вложенные транзакции, это лучше проверять кодом, а не только договорённостью:
var ErrNestedTransaction = errors.New("nested transaction is not allowed")
func (m *GormManager) InTransaction(
ctx context.Context,
fn func(context.Context) error,
) error {
if err := ctx.Err(); err != nil {
return err
}
if _, err := ExtractTx(ctx); err == nil {
return ErrNestedTransaction
}
return m.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
txCtx := context.WithValue(ctx, txKey, tx)
return fn(txCtx)
})
}
Так случайный вложенный вызов завершится понятной ошибкой, а не создаст отдельную транзакцию.
Как подключаются репозитории
Основное подключение репозиторий получает при создании, а рабочее выбирает заново на каждый вызов через dbFromContext. Вне InTransaction этот метод отдаёт r.db, внутри транзакции ExtractDB возвращает tx из контекста. Сигнатура метода при этом не меняется, а COMMIT и ROLLBACK остаются за менеджером.
Это не всегда означает настоящий autocommit. По умолчанию GORM сам оборачивает операции записи в отдельные транзакции. Такое поведение отключается параметром SkipDefaultTransaction; подробности есть в документации GORM.
Каждый метод репозитория должен обращаться к dbFromContext. Прямой вызов r.db.WithContext(ctx) внутри внешней транзакции тихо выполнит запрос через основное подключение, и внешний ROLLBACK его не отменит.
Частый источник ошибки заключается в передаче исходного ctx вместо txCtx:
return uc.tm.InTransaction(ctx, func(txCtx context.Context) error {
// Ошибка: репозиторий не увидит транзакцию из txCtx.
return uc.orders.Create(ctx, order)
})
Правильный вызов:
return uc.tm.InTransaction(ctx, func(txCtx context.Context) error {
return uc.orders.Create(txCtx, order)
})
Граница транзакции в use case
Execute из листинга координирует три репозитория и при этом не импортирует GORM: в его коде нет ни одного типа из инфраструктуры, только Manager и собственные зависимости. Три записи вместо трёх независимых операций из начала статьи стали одной границей.
Возврат любой ошибки из callback приводит к ROLLBACK. Значение nil разрешает менеджеру выполнить COMMIT. Ошибку Commit также вернёт метод Transaction, поэтому вызывающий код не должен считать операцию успешной до завершения InTransaction.
Что делать с обязательной транзакцией
Fallback из ExtractDB удобен для обычных CRUD-методов, которые допустимо вызывать отдельно. Но некоторым операциям запрещено работать вне транзакции.
Например, адаптер очереди записывает задачу в PostgreSQL рядом с бизнес-данными. Если он молча использует основное подключение, задача может сохраниться после отката заказа. Для такого контракта нужен строгий ExtractTx:
func (q *JobQueue) Enqueue(
ctx context.Context,
args PublishOrderArgs,
) error {
tx, err := transaction.ExtractTx(ctx)
if err != nil {
return fmt.Errorf("extract transaction: %w", err)
}
return q.insertWithGormTx(ctx, tx, args)
}
Выбор между ExtractDB и ExtractTx входит в контракт метода:
- метод может работать самостоятельно:
ExtractDB; - операция обязана быть атомарной с вызывающим кодом:
ExtractTx.
Как context управляет жизненным циклом
Недостаточно проверить ctx.Err() перед началом транзакции:
if err := ctx.Err(); err != nil {
return err
}
Такая проверка отсекает уже отменённый контекст, но он может отмениться сразу после неё. Поэтому ctx нужно передать самому GORM до вызова Transaction:
db := ExtractDB(ctx, m.db).WithContext(ctx)
database/sql.BeginTx использует переданный контекст до завершения транзакции. Если контекст отменён, пакет откатывает транзакцию, а Commit возвращает ошибку. Это поведение описано в документации database/sql.
Вызов WithContext только внутри отдельных методов репозитория отменяет соответствующие SQL-запросы, но не обязательно связывает с request context начало и завершение всей транзакции. Поэтому контекст должен быть установлен и перед Transaction, и перед запросами.
Контекст с транзакцией нельзя сохранять или передавать фоновой горутине, которая продолжит работу после выхода из callback:
return tm.InTransaction(ctx, func(txCtx context.Context) error {
go repo.Update(txCtx, orderID)
return nil
})
Когда горутина выполнит запрос, транзакция уже может быть закрыта. Фоновая работа должна получить собственный контекст с подходящим временем жизни и открыть собственную транзакцию.
Пессимистическая блокировка
Общая транзакция позволяет безопасно объединить чтение с блокировкой, проверку состояния и запись:
func (uc *PayOrderUseCase) Execute(
ctx context.Context,
orderID int64,
) error {
return uc.tm.InTransaction(ctx, func(txCtx context.Context) error {
order, err := uc.orders.GetForUpdate(txCtx, orderID)
if err != nil {
return err
}
if !order.CanBePaid() {
return ErrOrderCannotBePaid
}
return uc.orders.MarkPaid(txCtx, orderID)
})
}
Репозиторий добавляет SELECT FOR UPDATE:
func (r *OrderRepository) GetForUpdate(
ctx context.Context,
orderID int64,
) (Order, error) {
var order Order
err := r.dbFromContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", orderID).
Take(&order).Error
if err != nil {
return Order{}, fmt.Errorf("select order for update: %w", err)
}
return order, nil
}
В PostgreSQL блокируются найденные строки. Конкурирующий UPDATE, DELETE или другой locking-запрос к той же строке будет ждать завершения текущей транзакции. Обычный SELECT не блокируется, а если строка не найдена, строковой блокировки не возникает. Также ожидание может закончиться дедлайном или обнаружением deadlock. Точные правила описаны в документации PostgreSQL.
Транзакция должна оставаться короткой. Внешние HTTP-вызовы и другая долгая работа не должны выполняться под строковой блокировкой. При этом проверки, зависящие от прочитанного и заблокированного состояния, нельзя бездумно выносить за транзакцию: между чтением и записью данные могут измениться.
Ограничения подхода
Tx-in-context уменьшает количество инфраструктурных аргументов, но делает зависимость неявной. Официальная документация Go рекомендует использовать значения контекста для данных, связанных с текущей операцией и проходящих через границы API, а не как универсальный контейнер параметров. Транзакция под это описание подходит по времени жизни, но остаётся значением, о котором сигнатура метода не говорит ничего.
У подхода есть несколько рисков:
- новый метод репозитория может обратиться напрямую к
r.db; - вызывающий код может передать в репозиторий не тот контекст;
- fallback способен скрыть отсутствие обязательной транзакции;
- из интерфейса менеджера не видны уровень изоляции и режим read-only;
- транзакционный
Contextнельзя использовать после выхода из callback.
Часть рисков снижается соглашениями и тестами. Для критичных операций нужен строгий ExtractTx. Если приложению часто требуются разные уровни изоляции, read-only-транзакции или полностью явные зависимости, Unit of Work либо отдельный объект транзакционной сессии может оказаться понятнее.
Unit of Work как явная альтернатива
Мартин Фаулер определяет Unit of Work как объект, который отслеживает изменения в рамках бизнес-транзакции, а затем координирует их запись и разрешение конфликтов. В исходной формулировке паттерн тесно связан с объектной моделью: Unit of Work запоминает созданные, изменённые и удалённые объекты, после чего сохраняет накопленные изменения одной транзакцией.
В Go этим названием часто обозначают более узкую конструкцию: объект открывает один sql.Tx, создаёт на нём набор репозиториев и передаёт этот набор в callback. Use case явно работает с транзакционными репозиториями:
type Stores struct {
Orders OrderRepository
Items OrderItemRepository
Payment PaymentRepository
}
type UnitOfWork interface {
RunInTx(
ctx context.Context,
fn func(Stores) error,
) error
}
Внутри callback невозможно случайно принять основное подключение за транзакционное: нужные репозитории переданы аргументом. Но остаётся другая возможная ошибка: обратиться к полю исходного use case вместо репозитория из Stores.
Подробный практический вариант показан в статье Repositories, transactions, and unit of work in Go. Автор начинает с интерфейса DBTX, затем показывает транзакцию одного репозитория и приходит к UnitOfWork, который создаёт несколько репозиториев на одном sql.Tx.
В том же материале есть критика передачи транзакции через context: зависимость не видна в сигнатуре, неправильный контекст приводит к молчаливому fallback на пул соединений. Эта критика применима и к реализации из нашей статьи. Отличие лишь в том, что use case не кладёт *sql.Tx в контекст самостоятельно и не знает о database/sql или GORM. Это делает менеджер. Риск передать исходный ctx вместо txCtx всё равно остаётся.
Разница между двумя API выглядит так:
- tx-in-context сохраняет обычные интерфейсы репозиториев и компактные сигнатуры, но требует дисциплины при передаче контекста;
- Unit of Work явно передаёт транзакционные репозитории в callback, но добавляет контейнер
Storesи требует собирать его для каждой транзакции.
Для небольшого числа стабильных репозиториев явный Unit of Work может оказаться понятнее. Когда репозитории вызываются глубоко по стеку, а context.Context уже проходит через все методы, менеджер из этой статьи требует меньше дополнительного кода.
Когда использовать готовый менеджер
Небольшой менеджер несложно написать самостоятельно, но в production-реализации приходится учитывать вложенность, savepoints, настройки транзакции, несколько баз данных и особенности конкретного драйвера.
Если собственная реализация не входит в задачи проекта, можно использовать go-transaction-manager от Avito. Библиотека реализует тот же общий принцип: менеджер управляет границей, а репозиторий получает транзакцию или основное подключение через context getter. Есть адаптеры для database/sql, sqlx, GORM, pgx, MongoDB и Redis, а также поддержка вложенных транзакций.
Наличие готовой библиотеки не отменяет архитектурных решений. Перед подключением нужно определить:
- разрешены ли вложенные транзакции и какая семантика ожидается;
- нужен ли отдельный ключ контекста для каждой базы данных;
- какие уровни изоляции использует приложение;
- допустим ли fallback на основное подключение;
- как проверяются rollback и отмена контекста в интеграционных тестах.
README библиотеки отдельно отмечает, что для нескольких баз нужно задавать разные context keys, а для вложенных операций через разные менеджеры использовать специальную цепочку middleware. Это тот же класс проблем, ради которого в собственной реализации нельзя безусловно вызывать m.db.Transaction.
Как тестировать
Для unit-теста оркестрации можно использовать простой fake:
type FakeManager struct {
Err error
}
func (m FakeManager) InTransaction(
ctx context.Context,
fn func(context.Context) error,
) error {
if m.Err != nil {
return m.Err
}
return fn(ctx)
}
Такой fake проверяет, как use case вызывает зависимости и возвращает ошибки, но не доказывает работу транзакции. Для менеджера и репозиториев нужны интеграционные тесты с настоящей базой данных.
Минимальный набор сценариев:
- Все операции успешны, изменения зафиксированы.
- Второй репозиторий возвращает ошибку, изменения первого откатились.
- Вложенный
InTransactionуспешен, затем внешний callback возвращает ошибку. Откатились все изменения. - Вложенный callback возвращает ошибку. Поведение соответствует выбранной политике savepoint.
- Контекст отменён, поэтому
Commitне проходит и данные не сохраняются. - Метод со строгим
ExtractTxвызван вне транзакции и возвращаетErrTransactionNotFound.
Третий тест обнаруживает реализацию, которая всегда вызывает m.db.Transaction и создаёт независимую внутреннюю транзакцию. Без него эта ошибка не проявляется: вложенный callback отработает, данные сохранятся, и разойдутся они только при откате внешней границы.
Практические правила
Рабочую реализацию можно проверить по короткому списку:
- Граница транзакции находится в use case, который знает всю бизнес-операцию.
- Use case зависит от интерфейса менеджера, а не от
*gorm.DB. - Перед
Transactionменеджер сначала выполняетExtractDB. - Перед открытием транзакции вызывается
WithContext(ctx). - Все репозитории внутри callback получают
txCtx, а не исходныйctx. - Каждый метод репозитория выбирает подключение через
ExtractDB. - Операции, которым запрещена работа вне транзакции, используют
ExtractTx. - Вложенность либо корректно работает через savepoints, либо явно запрещена ошибкой.
- Транзакционный контекст не покидает callback и не передаётся фоновой горутине.
- Rollback, вложенность и отмена контекста проверены интеграционными тестами.
Tx-in-context относится только к одной локальной транзакции. Он не даёт атомарности между несколькими базами или внешними системами, зато не протаскивает тип конкретного драйвера или ORM в бизнес-логику.
Материалы
- документация пакета context;
- database/sql: BeginTx и жизненный цикл транзакции;
- работа с context в GORM;
- транзакции, savepoints и вложенные транзакции в GORM;
- уровни изоляции PostgreSQL;
- явные и строковые блокировки PostgreSQL;
- Unit of Work в каталоге Мартина Фаулера;
- репозитории, транзакции и Unit of Work в Go;
- готовый go-transaction-manager от Avito.