The Daily Newsstand · Free, Always
Sunday, September 27, 2026

Все (я надеюсь) способы работать с транзакциями в go

Translate

Так уж вышло, что я до сих пор не могу подобрать лучший способ для работы с транзакциями. Поэтому я решил написать эту обзорную статью. Я разберу все известные мне паттерны для работы с транзакциями и постараюсь выбрать лучший.

Вводные

Рассмотрим самый дефолтный проект с любой реляционной БД. У проекта есть две сущности - пользователи и заказы. Каждая сущность хранится в своей таблице. Мы пытаемся (насколько это возможно) не мешать слой БД со слоем бизнес-логики и стараемся писать поддерживаемый и расширяемый код.

В примерах кода специально упрощены сигнатуры функций, чтобы не переусложнять.

Столкнувшись с необходимостью работать с транзакциями, нам придётся выбрать один из этих вариантов:

  1. Используем объект транзакции в слое бизнес-логики

  2. Менеджер транзакций

  3. Агрегаты

  4. Один репозиторий на всё

  5. CQRS (ver. 1)

  6. CQRS (ver. 2)

  7. UOW (unit of work)

  8. Callback

  9. Заключение

1. Используем объект транзакции в слое бизнес-логики

Рассмотрим ситуацию, когда нам надо одновременно сохранить в БД и юзера, и заказ. Тогда можно написать примерно такой код:

package handler

import (
  "database/sql"
  "model"
  "context"
)

type UsersRepository {
  Save(ctx context.Context, user model.User, tx *sql.Tx)
}

type OrdersRepository {
  Save(ctx context.Context, order model.Order, tx *sql.Tx)
}

type CreateHandler struct {
  usersDb UsersRepository
  ordersDb OrdersRepository

  db *sql.Db
}

func (h *CreateHandler) Create(user model.User, order model.Order) error {
  tx := h.db.Begin()
  defer tx.Rollback()

  h.usersDb.Save(user, tx)
  h.ordersDb.Save(order, tx)

  return tx.Commit()
}

Плюс у такого подхода ровно один: это простой код, который поймёт разработчик любого уровня.

Но минусов намного больше:

  • Что делать, если мы в другом хендлере захотим переиспользовать метод Save из репозитория без транзакции?

  • В слой бизнес-логики протекла логика работы с БД

  • Если мы захотим переехать с database/sql на другую библиотеку для работы с БД, скорее всего придётся править много кода в слое бизнес-логики

  • Велика вероятность ошибки, так как разработчик может забыть написать tx.Commit или tx.Rollback

  • При появлении вложенных транзакций или необходимости указывать явно уровень изоляции код становится сильно менее читаемым

На самом деле примерно такой код я чаще всего и вижу в реальных проектах. И подозреваю, что в комментариях будет много защитников такого подхода, так как в сообществе go очень любят простоту.

2. Менеджер транзакций

Предыдущий способ, видимо, не понравился не только мне, поэтому разрабы из авито сделали свой менеджер транзакций. Он хранит транзакции в контексте и позволяет работать с ними примерно так:

package handler

import (
  "model"
  "context"
  "github.com/avito-tech/go-transaction-manager/trm/v2/manager"
)

type UsersRepository {
  Save(ctx context.Context, user model.User)
}

type OrdersRepository {
  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, но имхо это притягивание за уши.

Фактически разработчики просто заметили, что контекст, который всегда передаётся в любой метод репозитория, случайно имеет удобные методы для хранения в нём транзакции. Хотя изначально они использовали его для других целей.

3. Агрегаты

Мы все привыкли, что для каждой сущности создаётся свой репозиторий. Для клиентов создаётся UsersRepository, для заказов - OrdersRepository и т. д. Но вообще это не какое-то золотое правило, которое нельзя нарушать. Некоторые справедливо заметят, что репозитории надо делить не по таблицам в БД, а по бизнес-сущностям.

Если какой-то процесс подразумевает транзакционное создание и заказа, и юзера, то почему мы должны делить эту операцию на две? Мы можем выделить агрегат, состоящий из заказа и пользователя, и наш код будет выглядеть вот так:

package handler

import (
  "model"
  "context"
)

type UsersRepository {
  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 {
  Save(ctx context.Context, user model.User, tx *sql.Tx) 
}

type OrdersRepository {
  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()
}

4. Один репозиторий на всё

Сразу оговорюсь, что это экстремальный метод, который у многих вызовет отторжение. Но у него хватает своих плюсов, поэтому не могу не сказать о нём. Идея довольно простая: мы сделаем один репозиторий на все сущности проекта и добавим удобный метод для транзакций:

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.

5. CQRS (ver. 1)

Можно чуть усложнить предыдущий вариант и отказаться от паттерна репозиторий. Вместо этого мы разделим все наши запросы к БД на команды (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. Имхо это одна из самых толковых либ для работы с БД, но многих пугает кодогенерация как явление.

6. CQRS (ver. 2)

Если в предыдущем варианте совсем не нравится метод 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 в коде хочется избегать примерно всегда.

Из всех подходов этот мне, наверное, нравится меньше всех остальных.

7. UOW (unit of work)

Честно скажу, что я этим паттерном никогда не пользовался ни в 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 {
  Save(ctx context.Context, user model.User)
}

type OrdersRepository {
  Save(ctx context.Context, order model.Order)
}

type UnitOfWork interface {
  func (u *UnitOfWork) GetUsersRepository() UsersRepository
  func (u *UnitOfWork) GetOrdersRepository() OrdersRepository
  func (u *UnitOfWork) 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 и пр.

8. Callback

Предлагаю немного другую задачу. Теперь в хендлере нам надо запросить какого-то пользователя, залочить его, а потом деактивировать его, если он не совершил ни одной покупки. Тогда можно написать примерно такой код:

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 *CreateHandler) Create(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
}

Надо ли при этом лочить заказы? Если да, то до или после селекта юзера? А если нам в каких-то случаях надо изменить порядок локов? Ну и такой подход банально не работает для случая, когда нам надо создать какую-то другую сущность в этой же транзакции.

У меня был опыт работы в команде, где все микросервисы были настолько маленькие, что у каждого была ровно одна таблица. Там такой подход идеально бы зашёл. Но увы, это слишком узкий кейс.

Заключение

Наверняка какие-то подходы я пропустил, но все наиболее распространённые я вроде бы рассмотрел. Честно говоря, всё ещё не знаю, какой из них лучше других. Мне одновременно хочется читаемый и удобный вариант без протекания слоёв, но не похоже, что это возможно.

В статьях часто просят обратную связь ради трафика и подписчиков. Мне на это всё равно, но вот мнение сообщества мне действительно интересно. Буду рад прочитать ваши примеры, подходы и библиотеки для работы с транзакциями в go.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.