Задача
Один бизнес-сценарий часто изменяет данные через несколько репозиториев. Все операции должны завершиться вместе, но передавать транзакцию аргументом каждого метода неудобно, а открывать её внутри одного репозитория недостаточно.
Суть
В статье транзакция хранится в context.Context, её открывает use case, а перед каждым вложенным Transaction менеджер сначала выполняет ExtractDB.
В примерах используется GORM, но идея не привязана к ORM. Аналогичный менеджер можно написать поверх database/sql, pgx или другого способа работы с PostgreSQL. Меняется инфраструктурная реализация, а use case по-прежнему зависит только от небольшого интерфейса.
Содержание
Где возникает проблема
Представим оформление заказа. Сценарий должен:
Создать заказ.
Добавить его позиции.
Изменить состояние платежа.
Если третья операция завершится ошибкой, первые две также должны откатиться. Значит, все репозитории должны использовать одну транзакцию.
Без общей границы код легко превращается в три независимые записи:
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 придётся менять код выше слоя хранения.
Реализация целиком
Весь механизм умещается в один пакет. Ниже он приведён полностью, а дальше по статье разобран по частям.
Cперва объявляем интерфейс:
// manager.go package transaction import ( "context" ) type Manager interface { InTransaction( ctx context.Context, fn func(context.Context) error, ) error }
Далее реализацию(в нашем случае для gorm):
// gorm_manager.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 с единственным методом.
Функция получает производный контекст. Все вызовы репозиториев внутри него должны использовать именно этот контекст.
Менеджер отвечает за границу транзакции, а репозиторий выполняет запросы на правильном подключении. Репозиторий знает о механизме 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 не допускает грязное чтение.
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 сам оборачивает операции записи в отдельные транзакции.
Каждый метод репозитория должен обращаться к 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 не блокируется, а если строка не найдена, строковой блокировки не возникает.
Транзакция должна оставаться короткой. Внешние HTTP-вызовы и другая долгая работа не должны выполняться под строковой блокировкой. При этом проверки, зависящие от прочитанного и заблокированного состояния, нельзя бездумно выносить за транзакцию: между чтением и записью данные могут измениться.
Ограничения подхода
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. Это делает менеджер(usecase знает только об интерфейсе менеджера). Риск передать исходный ctx вместо txCtx всё равно остаётся.
Разница между двумя API выглядит так:
Transaction-in-context сохраняет обычные интерфейсы репозиториев и компактные сигнатуры, но требует дисциплины при передаче контекста;
Unit of Work явно передаёт транзакционные репозитории в callback, но добавляет контейнер
Storesи требует собирать его для каждой транзакции.
Для небольшого числа стабильных репозиториев явный Unit of Work может оказаться понятнее. Когда репозитории вызываются глубоко по стеку, а context.Context уже проходит через все методы, менеджер из этой статьи требует меньше дополнительного кода.
Когда использовать готовый менеджер
Небольшой менеджер несложно написать самостоятельно, но в production-реализации приходится учитывать вложенность, savepoints, настройки транзакции, несколько баз данных и особенности конкретного драйвера.
Если собственная реализация не входит в задачи проекта, можно использовать go-transaction-manager от Avito.
Наличие готовой библиотеки не отменяет архитектурных решений. Перед подключением нужно определить:
разрешены ли вложенные транзакции и какая семантика ожидается;
нужен ли отдельный ключ контекста для каждой базы данных;
какие уровни изоляции использует приложение;
допустим ли fallback на основное подключение;
как проверяются rollback и отмена контекста в интеграционных тестах.
Как тестировать
Для 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 в бизнес-логику.
Материалы
Мой телеграм-канал: @tsymbaldev.
micronull
Хорошая идея, особенно с оберткой
InTransaction, взял на вооружение.Не так давно решал такую же проблему, контекст тоже рассматривал. Для меня стояла задача сохранить чистые от транзакций репозитории. Чтоб они не знали с чем работают, транзакция это или просто соединение с базой.
В итоге с помощью дженериков сделал композитор, который оборачивает репозиторий и при вызове транзакции создает его, передав вместо db tx. В итоге все получилось, однако есть проблема в том что этот подход был реализован только для одного репозитория.
После вашей статьи захотелось развить свое решение для поддержки нескольких репозиториев, если получится напишу статью.
Спасибо!
vovkats Автор
Спасибо большое за фидбек. Честно скажу, о подходе Unit of Work я узнал позже. Он показался мне интересным, но особого профита для своего проекта я не увидел.
Как мне кажется, в таком случае use case должен принимать именно Unit of Work вместо репозиториев. И лучше не смешивать подходы, когда одни use case принимают репозитории, а другие unit of work, то есть всё должно быть в одном стиле.