Все (я надеюсь) способы работать с транзакциями в go
- вторник, 29 сентября 2026 г. в 00:00:17
Так уж вышло, что я до сих пор не могу выбрать лучший способ для работы с транзакциями. Поэтому я решил написать эту обзорную статью. Я разберу все известные мне паттерны для работы с транзакциями и постараюсь выбрать лучший.
Рассмотрим самый дефолтный проект с любой реляционной БД. У проекта есть две сущности — пользователи и заказы. Каждая сущность хранится в своей таблице. Мы пытаемся (насколько это возможно) не мешать слой БД со слоем бизнес‑логики и стараемся писать поддерживаемый и расширяемый код.
В примерах кода специально упрощены сигнатуры функций, чтобы не переусложнять.
Столкнувшись с необходимостью работать с транзакциями, нам придётся выбрать один из этих вариантов:
Рассмотрим ситуацию, когда нам надо одновременно сохранить в БД и юзера, и заказ. Тогда можно написать примерно такой код:
package handler import ( "database/sql" "model" "context" ) type UsersRepository interface { Save(ctx context.Context, user model.User, tx *sql.Tx) } type OrdersRepository interface { Save(ctx context.Context, order model.Order, tx *sql.Tx) } type CreateHandler struct { usersDb UsersRepository ordersDb OrdersRepository db *sql.DB } func (h *CreateHandler) Create(ctx context.Context, user model.User, order model.Order) error { tx := h.db.Begin() defer tx.Rollback() h.usersDb.Save(ctx, user, tx) h.ordersDb.Save(ctx, order, tx) return tx.Commit() }
Плюс у такого подхода ровно один: это простой код, который поймёт разработчик любого уровня.
Но минусов намного больше:
Что делать, если мы в другом хендлере захотим переиспользовать метод Save из репозитория без транзакции?
В слой бизнес‑логики протекла логика работы с БД
Если мы захотим переехать с database/sql на другую библиотеку для работы с БД, скорее всего придётся править много кода в слое бизнес‑логики
Велика вероятность ошибки, так как разработчик может забыть написать tx.Commit или tx.Rollback
При появлении вложенных транзакций или необходимости указывать явно уровень изоляции код становится сильно менее читаемым
Часть этих проблем внешние библиотеки успешно решают. Например, в pgx есть метод BeginFunc, который может чуть упростить код выше:
package handler import ( "github.com/jackc/pgx/v5" "model" "context" ) type UsersRepository interface { Save(ctx context.Context, user model.User, tx pgx.Tx) } type OrdersRepository interface { Save(ctx context.Context, order model.Order, tx pgx.Tx) } type CreateHandler struct { usersDb UsersRepository ordersDb OrdersRepository db *pgx.Conn } func (h *CreateHandler) Create(ctx context.Context, user model.User, order model.Order) error { return pgx.BeginFunc(ctx, h.db, func(tx pgx.Tx) error { h.usersDb.Save(ctx, user, tx) h.ordersDb.Save(ctx, order, tx) return nil }) }
На самом деле примерно такой код я чаще всего и вижу в реальных проектах. И подозреваю, что в комментариях будет много защитников такого подхода, так как в сообществе go очень любят простоту.
Предыдущий способ, видимо, не понравился не только мне, поэтому разрабы из авито сделали свой менеджер транзакций. Он хранит транзакции в контексте и позволяет работать с ними примерно так:
package handler import ( "model" "context" "github.com/avito-tech/go-transaction-manager/trm/v2/manager" ) type UsersRepository interface { Save(ctx context.Context, user model.User) } type OrdersRepository interface { Save(ctx context.Context, order model.Order) } type CreateHandler struct { usersDb UsersRepository ordersDb OrdersRepository txManager manager.Manager } func (h *CreateHandler) Create(ctx context.Context, user model.User, order model.Order) error { // метод Do создаёт транзакцию и сохраняет её в контекст // если колбэк возвращает ошибку, транзакция откатывается return h.txManager.Do(ctx, func(ctx context.Context) error { h.usersDb.Save(ctx, user) h.ordersDb.Save(ctx, order) return nil }) }
Вообще этот подход мне критиковать сложнее всего, хоть он и не очень мне нравится. Код всё ещё достаточно простой и читабельный, про откат транзакции думать не надо, можно переиспользовать методы репозитория внутри и вне транзакций. Но этот подход с хранением транзакции в контексте лично меня слишком отталкивает. В документации go по этому поводу сказано следующее:
Use context Values only for request‑scoped data that transits processes and APIs, not for passing optional parameters to functions.
Формально транзакцию можно рассматривать как request‑scoped data, но имхо это притягивание за уши.
Фактически разработчики просто заметили, что контекст, который всегда передаётся в любой метод репозитория, случайно имеет удобные методы для хранения в нём транзакции. Хотя изначально они использовали его для других целей.
Мы все привыкли, что для каждой сущности создаётся свой репозиторий. Для клиентов создаётся UsersRepository, для заказов — OrdersRepository и так далее. Но вообще это не какое‑то золотое правило, которое нельзя нарушать. Некоторые справедливо заметят, что репозитории надо делить не по таблицам в БД, а по бизнес‑сущностям.
Если какой‑то процесс подразумевает транзакционное создание и заказа, и юзера, то почему мы должны делить эту операцию на две? Мы можем выделить агрегат, состоящий из заказа и пользователя, и наш код будет выглядеть вот так:
package handler import ( "model" "context" ) type UsersRepository interface { Save(ctx context.Context, user model.UserWithOrder) } type CreateHandler struct { usersDb UsersRepository } func (h *CreateHandler) Create(ctx context.Context, user model.User, order model.Order) error { userWithOrder := model.NewUserWithOrder(user, order) h.usersDb.Save(userWithOrder) return nil }
Мне очень нравится такой подход. Я всегда стараюсь следовать именно ему, но тут тоже есть свои нюансы. Иногда всё же возможна ситуация, что в другом хендлере нам потребуется создать заказ отдельно от пользователя. И тогда нам придётся либо как‑то шарить логику создания заказа между двумя репозиториями, либо делать новый слой, который знает про репозитории, но не знает про бизнес‑логику. Выглядеть он будет примерно так:
package storage import ( "database/sql" "model" "context" ) type UsersRepository interface { Save(ctx context.Context, user model.User, tx *sql.Tx) } type OrdersRepository interface { Save(ctx context.Context, order model.Order, tx *sql.Tx) } type Storage struct {} // из хендлеров будет вызываться этот метод // репозитории в нём больше не используются func (s *Storage) Create(ctx context.Context, user model.UserWithOrder) error { tx := h.db.Begin() defer tx.Rollback() h.usersDb.Save(user.User, tx) h.ordersDb.Save(user.Order, tx) return tx.Commit() }
Сразу оговорюсь, что это экстремальный метод, который у многих вызовет отторжение. Но у него хватает своих плюсов, поэтому не могу не сказать о нём. Идея довольно простая: мы сделаем один репозиторий на все сущности проекта и добавим удобный метод для транзакций:
package handler import ( "model" "context" ) // один репозиторий для всех сущностей проекта (даже если сущностей очень много) type Repository interface{ SaveUser(ctx context.Context, user model.User) SaveOrder(ctx context.Context, order model.Order) InTx(ctx context.Context, f func(ctx context.Context, tx Repository) error) } type CreateHandler struct { db Repository } func (h *CreateHandler) Create(ctx context.Context, user model.User, order model.Order) error { return h.db.InTx(ctx, func(ctx context.Context, tx Repository) error { tx.SaveUser(ctx, user) tx.SaveOrder(ctx, order) return nil }) }
Да, многим такое не зайдёт. Наверняка в голове сразу возникнут мысли про god object, big ball of mud (большой комок грязи), нарушение SOLID и пр. Но мне кажется, что такой подход не так уж и плох. Я легко пойму разработчиков, которые выберут именно этот вариант в своих проектах.
Кстати, подобный подход реализован в библиотеке ent, которая косит под entity framework.
Да, CQRS придумывали для другого. Здесь речь идёт скорее про некое подобие CQRS.
Можно чуть усложнить предыдущий вариант и отказаться от паттерна репозиторий. Вместо этого мы разделим все наши запросы к БД на команды (command) и запросы (query), которые сможет обрабатывать один executor:
package cqrs import ( "model" "context" "command" "database/sql" ) type Command interface { Handle(ctx context.Context, conn *sql.Conn) error } type Query[T any] interface { Handle(ctx context.Context, conn *sql.Conn) (T, error) } type Executor interface { RunCommand(ctx context.Context, command Command) error RunQuery(ctx context.Context, query Query[any]) (any, error) InTx(ctx context.Context, f func(ctx context.Context, e Executor) error) error }
И тогда итоговый код будет выглядеть примерно так:
package handler import ( "model" "context" "command" ) type Executor interface{ RunCommand(ctx context.Context, command Command) error InTx(ctx context.Context, f func(ctx context.Context, e Executor) error) error } type CreateHandler struct { db Executor } func (h *CreateHandler) Create(ctx context.Context, user model.User, order model.Order) error { return h.db.InTx(ctx, func(ctx context.Context, tx Executor) error { tx.RunCommand(ctx, command.SaveUser{User: user}) tx.RunCommand(ctx, command.SaveOrder{Order: order})) return nil }) }
Теперь у нас нет одного гигантского репозитория, где хранятся все методы для работы со всеми сущностями. Но наружу торчат методы Handle, где в сигнатуре наружу торчит тип sql.Tx. Да, формально никто не будет его вызывать напрямую, но чувство прекрасного всё равно задето.
В C# для таких вещей используют MediatR. В go не нашёл популярных аналогов, хотя sqlc делает что-то похожее.
Если в предыдущем подходе совсем не нравится метод Handle, то можно рассмотреть такой вариант:
package handler import ( "model" "context" "command" ) type Executor interface { RunCommand(ctx context.Context, command any) error InTx(ctx context.Context, f func(ctx context.Context, e Executor) error) error } type CreateHandler struct { db Executor } func (h *CreateHandler) Create(ctx context.Context, user model.User, order model.Order) error { return h.db.InTx(ctx, func(ctx context.Context, tx Executor) error { tx.RunCommand(ctx, command.SaveUser{User: user}) tx.RunCommand(ctx, command.SaveOrder{Order: order})) return nil }) }
Теперь у команды нет метода Handle с торчащим наружу типом sql.Tx. Вместо этого для каждой команды создаётся отдельный хендлер:
package cqrs import ( "context" "command" ) type SaveUserHandler struct {} func (h *SaveUserHandler) Handle(ctx context.Context, query command.SaveUser) error { // тут логика сохранения юзера в БД }
Проблема начинается на этапе связывания команд и их хендлеров. Приходится либо подключать рефлексию, либо использовать кодогенерацию, либо вручную при сборе зависимостей указывать какой хендлер должен вызываться для каждой комманды. Ну и использование any в коде хочется избегать примерно всегда.
Из всех подходов этот мне, наверное, нравится меньше всех остальных.
Честно скажу, что я этим паттерном никогда не пользовался ни в go, ни в C#. В книгах про паттерны я тоже его не встречал (либо просто забыл об этом). Поэтому здесь я полагаюсь на чатгпт и мнение сообщества, которое почему‑то не сходится в одном мнении.
На хабре в комментариях пользователь hello_my_name_is_dany пишет, что UoW придумали для шаринга одного коннекта между несколькими репозиториями, а удобство работы с транзакциями — это случайный приятный бонус.
Фаулер на своём сайте с ним не соглашается:
A Unit of Work keeps track of everything you do during a business transaction that can affect the database. When you're done, it figures out everything that needs to be done to alter the database as a result of your work.
Имхо звучит немного расплывчато. Да и про транзакции вообще ничего не сказано. В любом случае не хочу превращать эту статью в ресёрч паттерна UoW, поэтому предлагаю реализацию, которая чаще всего приводится в других статьях на эту тему:
package handler import ( "context" "model" ) type UsersRepository interface { Save(ctx context.Context, user model.User) } type OrdersRepository interface { Save(ctx context.Context, order model.Order) } type UnitOfWork interface { GetUsersRepository() UsersRepository GetOrdersRepository() OrdersRepository Transaction(fn func() error) } type CreateHandler struct { usersDb UsersRepository ordersDb OrdersRepository uow UnitOfWork } func (h *CreateHandler) Create(ctx context.Context, user model.User, order model.Order) error { return h.uow.Transaction(func() { h.uow.GetUsersRepository().Save(ctx, user) h.uow.GetOrdersRepository().Save(ctx, order) return nil }) }
Я не буду прикладывать конкретную реализацию интерфейса UnitOfWork, но чаще всего там приходится хранить все репозитории в одном месте примерно вот так:
package uow type UoW struct { users UsersRepository // конкретная реализация репозитория, а не просто интерфейс orders OrdersRepository // конкретная реализация репозитория, а не просто интерфейс db *slx.DB }
Конкретный пример реализации можно посмотреть всё в том же комментарии от hello_my_name_is_dany.
По сути мы просто взяли подход номер 4, но вместо создания одного гигантского репозитория мы сделали адаптер UoW, который хранит в себе все репозитории. Минусы по сути такие же: god object, big ball of mud (большой комок грязи), нарушение SOLID и пр.
Предлагаю немного другую задачу. Теперь в хендлере нам надо запросить какого‑то пользователя, залочить его, а потом деактивировать его, если он не совершил ни одной покупки. Тогда можно написать примерно такой код:
package handler import ( "context" "model" ) type UsersRepository interface { SelectForUpdate(ctx context.Context, userID int, func(u *model.User)) error } type UpdateHandler struct { users UsersRepository } func (h *UpdateHandler) Deactivate(ctx context.Context, userID int) error { return h.users.SelectForUpdate(ctx, userID, func(u *model.User) { if u.OrdersCount == 0 { // да, поле с количеством заказов лежит в таблице users, так уж вышло u.Active = false } }) }
Метод SelectForUpdate сделает следующее:
выполнит SELECT FOR UPDATE для поиска юзера
вызовет колбэк из нашего хендлера
сравнит, изменился ли наш юзер, и вызовет апдейт
Здесь хорошо почти всё, но на практике такой подход наиболее хрупкий из всех.
По мере добавления новых полей в model.User надо держать в голове, что где‑то есть репозиторий с методом для обновления юзера, где надо эти изменения поддержать. Да, можно использовать кодогенерацию и даже навесить её на хук перед коммитом, но это ощущается как слишком большой оверхед.
К тому же этот подход плохо работает с большими агрегатами. Допустим, мы хотим запросить вот такой объект:
type UserWithOrder struct { User User Orders []Order }
Надо ли при этом лочить заказы? Если да, то до или после селекта юзера? А если нам в каких‑то случаях надо изменить порядок локов? Ну и такой подход банально не работает для случая, когда нам надо создать какую‑то другую сущность в этой же транзакции.
У меня был опыт работы в команде, где все микросервисы были настолько маленькие, что у каждого была ровно одна таблица. Там такой подход идеально бы зашёл. Но увы, это слишком узкий кейс.
Наверняка какие‑то подходы я пропустил, но все наиболее распространённые я вроде бы рассмотрел. Честно говоря, всё ещё не знаю, какой из них лучше других. Мне одновременно хочется читаемый и удобный вариант без протекания слоёв, но не похоже, что это возможно.
Интуитивно лично мне хочется двигаться в сторону отказа от идеи, что для каждой таблицы в БД нужен свой репозиторий, но вариант с CQRS слишком навороченный, а идея с одним репозиторием на все таблицы кажется слишком отталкивающей. Почему‑то кажется, что есть более элегантное решение, которое я просто не замечаю.
В статьях часто просят обратную связь ради трафика и подписчиков. Мне на это всё равно, но вот мнение сообщества мне действительно интересно. Буду рад прочитать ваши примеры, подходы и библиотеки для работы с транзакциями в go.