golang

Unit of Work в Go: практический гайд по транзакциям между репозиториями

  • четверг, 25 июня 2026 г. в 00:00:12
https://habr.com/ru/companies/otus/articles/1049234/

Эта статья выросла из пары быстрых ответов на вопросы на Reddit в ветке r/golang. Первый был о том, стоит ли делать слой репозиториев поверх sqlc. Второй — о том, как работать с транзакциями, когда интерфейс скрывает детали хранилища. Оба ответа превратились в небольшие статьи. Здесь я собираю их вместе и разбираю, что делать, когда транзакции должны охватывать несколько репозиториев.

Разберём три этапа, каждый следующий опирается на предыдущий:

  1. поставить интерфейс репозитория между сервисной логикой и слоем хранения;

  2. добавить поддержку транзакций в один репозиторий, не протаскивая SQL в сервис;

  3. координировать транзакции между несколькими репозиториями через Unit of Work.

Во всех примерах используется SQLite. Рабочие примеры для варианта с одним хранилищем и для варианта с несколькими хранилищами есть на GitHub.

Что из себя представляет паттерн Репозиторий?

Мартин Фаулер в своей книге «Шаблоны архитектуры корпоративных приложений» определял паттерн «Репозиторий» так:

«Репозиторий» выступает посредником между доменным слоем и слоем маппинга данных, предоставляя похожий на коллекцию интерфейс для доступа к доменным объектам.

В Go репозиторий — это просто интерфейс. Сервис зависит от интерфейса, конкретный пакет его реализует, и живут они в разных пакетах. Сервис описывает, что ему нужно, а хранилище это предоставляет. Принцип инверсии зависимостей в действии.

Чтобы понять, зачем это нужно, посмотрим, что происходит без репозитория.

Что происходит без репозитория

Допустим, вы пишете сервис книжного магазина с sqlc. Сгенерированный код даёт структуру Queries с методами вроде GetBook и CreateBook. Самый соблазнительный вариант — внедрить её прямо в сервис:

type Service struct {
   q *db.Queries
}

func (s *Service) RegisterBook(
   ctx context.Context, title string) (db.Book, error) {
   return s.q.CreateBook(ctx, title)
}

Такой код компилируется и работает, но сервис теперь намертво привязан к сгенерированным типам sqlc. Каждый метод сервиса импортирует пакет db. Если вы захотите протестировать RegisterBook без базы данных, придётся мокать весь Queries или поднимать тестовую базу данных. Если позже вы перейдёте с sqlc на raw SQL или с Postgres на DynamoDB, переписывать придётся и сервисный слой.

Сервис должен описывать, что ему нужно от хранилища, не зная, как именно хранилище это делает. «Дай мне книгу по ID» и «создай эту книгу» — это что. SQL-запросы, пулы соединений и схемы таблиц — это как. Небольшой интерфейс решает эту проблему.

Добавляем интерфейс репозитория

Интерфейс живёт в пакете book рядом с доменными типами. Это пакет с бизнес-логикой. В нём нет импортов из database/sql или какой-либо библиотеки для работы с хранилищем:

// book/book.go

type Book struct {
   ID    int64
   Title string
}

type Store interface {
   Get(ctx context.Context, id int64) (Book, error)
   Create(ctx context.Context, b Book) (int64, error)
}

Два метода. Get получает книгу по ID, Create сохраняет новую книгу и возвращает сгенерированный ID. Интерфейс ничего не говорит про SQL, таблицы или пулы соединений. Его может реализовать любой бэкенд хранения, который умеет получать и создавать книги.

Сервис зависит только от Store:

// book/service.go

type Service struct {
   store Store
}

func NewService(s Store) *Service {
   return &Service{store: s}
}

func (s *Service) RegisterBook(
   ctx context.Context, title string) (Book, error) {

   b := Book{Title: title}
   id, err := s.store.Create(ctx, b)
   if err != nil {
       return Book{}, err
   }
   b.ID = id
   return b, nil
}

func (s *Service) GetBook(
   ctx context.Context, id int64) (Book, error) {
   return s.store.Get(ctx, id)
}

RegisterBook создаёт Book, просит хранилище сохранить её и получает ID обратно. Он не импортирует ничего из database/sql. У пакета book нет зависимостей от слоя хранения.

Теперь нужно что-то, что действительно ходит в базу данных.

Реализация для SQLite

Отдельный пакет sqlite реализует интерфейс Store. Здесь я пишу запросы вручную, чтобы не тащить всю обвязку sqlc, но структура была бы той же. sqlc просто сгенерировал бы методы запросов за вас.

Перед тем как писать методы хранилища, нужно подготовить одну вещь. sqlc генерирует интерфейс DBTX, которому удовлетворяют и sql.DB, и sql.Tx. sql.DB — это пул соединений, sql.Tx — транзакция:

// sqlite/store.go

type DBTX interface {
   ExecContext(
       ctx context.Context,
       query string, args ...any) (sql.Result, error)
   QueryRowContext(
       ctx context.Context,
       query string, args ...any) *sql.Row
}

Почему это важно? Потому что и у sql.DB, и у sql.Tx есть эти два метода. Любой код, написанный поверх DBTX, работает с обоими вариантами. Для базового репозитория это пока не нужно, но позже, когда мы добавим транзакции, это станет важным.

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

// sqlite/store.go

type BookStore struct{ db DBTX }

func NewBookStore(db DBTX) *BookStore { return &BookStore{db: db} }

Методы запросов вызывают s.db.ExecContext и s.db.QueryRowContext. Сейчас эти вызовы проходят через пул соединений *sql.DB:

// sqlite/store.go

func (s *BookStore) Get(
   ctx context.Context, id int64) (book.Book, error) {
   row := s.db.QueryRowContext(ctx,
       "SELECT id, title FROM books WHERE id = ?", id)
   var b book.Book
   err := row.Scan(&b.ID, &b.Title)
   return b, err
}

func (s *BookStore) Create(
   ctx context.Context, b book.Book) (int64, error) {
   res, err := s.db.ExecContext(ctx,
       "INSERT INTO books (title) VALUES (?)", b.Title)
   if err != nil {
       return 0, err
   }
   return res.LastInsertId()
}

Позже, когда мы добавим транзакции, s.db будет не sql.DB, а sql.Tx, и те же самые методы начнут выполняться внутри транзакции без единого изменения в коде. В этом и есть выигрыш от DBTX.

Связать всё при старте — по одной строке на зависимость:

// cmd/main.go

store := sqlite.NewBookStore(db)
svc := book.NewService(store)

Сервис получает Store, то есть интерфейс. Пакет sqlite получает *sql.DB, который удовлетворяет DBTX. Ни один из этих пакетов не импортирует другой.

Тестируем без базы данных

Раз сервис зависит от интерфейса, его можно тестировать без базы данных — достаточно написать in-memory fake:

// book/service_test.go

var _ Store = (*memStore)(nil)

type memStore struct {
   mu   sync.Mutex
   books map[int64]Book
   next int64
}

func (m *memStore) Get(
   ctx context.Context, id int64) (Book, error) {

   m.mu.Lock()
   defer m.mu.Unlock()
   b, ok := m.books[id]
   if !ok {
       return Book{}, fmt.Errorf("book %d not found", id)
   }
   return b, nil
}

func (m *memStore) Create(
   ctx context.Context, b Book) (int64, error) {

   m.mu.Lock()
   defer m.mu.Unlock()
   m.next++
   b.ID = m.next
   m.books[b.ID] = b
   return b.ID, nil
}

Строка var _ Store = (*memStore)(nil) — это проверка соответствия интерфейсу на этапе компиляции. Если memStore когда-нибудь перестанет удовлетворять Store, сборка упадёт.

Тест выглядит почти как production-код, только без базы:

// book/service_test.go

func TestRegisterBook(t *testing.T) {
   store := &memStore{books: make(map[int64]Book)}
   svc := NewService(store)

   b, err := svc.RegisterBook(t.Context(), "DDIA")
   if err != nil {
       t.Fatal(err)
   }
   if b.ID == 0 {
       t.Fatal("expected non-zero ID")
   }
   if b.Title != "DDIA" {
       t.Fatalf("got title %q, want DDIA", b.Title)
   }
}

Он выполняется за микросекунды и гоняет тот же код RegisterBook, который работает в проде. Если завтра слой хранения переедет с SQLite на Postgres, этот тест останется прежним, потому что зависит только от интерфейса.

Интеграционные тесты с настоящей базой всё равно нужны — скоро до них дойдём. Но основную часть сервисной логики можно тестировать на fake-реализациях.

Пока у нас есть чистое разделение: сервис говорит с интерфейсом, пакет sqlite его реализует, тесты используют in-memory fake. Но каждый метод интерфейса выполняется отдельно. Если RegisterBook должен сделать две записи, которые обязаны либо обе успешно завершиться, либо обе откатиться, появляется проблема.

Добавляем транзакции в один репозиторий

Допустим, бизнес-требования изменились. Теперь при регистрации книги нужно ещё писать запись в audit log: кто её создал и когда. Обе записи должны быть атомарными: если вставка книги прошла успешно, но audit log записать не удалось, нам не нужна книга в базе без следа в аудите. Значит, нужна транзакция.

Именно об этом спрашивал xinoiP на Reddit:
Как бы вы обрабатывали транзакции при таком подходе? Ведь они очень завязаны на SQL.

Чтобы поддержать новое требование, в интерфейс Store нужно добавить две вещи. Во-первых, тип AuditEntry и метод CreateAuditLog для записей аудита. Во-вторых, метод Tx, который позволяет сервису сгруппировать несколько операций в одну транзакцию:

// book/book.go

type AuditEntry struct {
   BookID int64
   Action string
}

type Store interface {
   Get(ctx context.Context, id int64) (Book, error)
   Create(ctx context.Context, b Book) (int64, error)
   CreateAuditLog(ctx context.Context, e AuditEntry) error

   // Tx выполняет fn внутри транзакции. Store, переданный
   // в fn, работает поверх этой транзакции.
   Tx(ctx context.Context, fn func(Store) error) error
}

CreateAuditLog — обычный метод доступа к данным, такой же как Get и Create. Интереснее здесь Tx. Он принимает callback-функцию, которая получает Store. Этот Store работает поверх транзакции базы данных, поэтому каждый вызванный на нём метод выполняется внутри этой транзакции. Идея похожа на передачу состояния под локом в замыкание. Вызывающий код не управляет жизненным циклом транзакции: никаких ручных begin/commit/rollback, как и никаких ручных lock/unlock. Он просто работает с тем, что получил в callback.

Вот как работает реализация Tx для SQLite:

// sqlite/store.go

func (s *BookStore) Tx(
   ctx context.Context,
   fn func(book.Store) error) error {

   sqlDB, ok := s.db.(*sql.DB)
   if !ok {
       return errors.New(
           "cannot start tx: already inside a transaction")
   }

   tx, err := sqlDB.BeginTx(ctx, nil)
   if err != nil {
       return err
   }
   defer tx.Rollback() // после Commit ничего не делает

   // Создаём новый BookStore поверх tx.
   if err := fn(NewBookStore(tx)); err != nil {
       return err
   }
   return tx.Commit()
}

Проверка типа s.db.(*sql.DB) убеждается, что под капотом у нас пул соединений, а не уже существующая транзакция. В database/sql нельзя вложить sql.Tx внутрь sql.Tx. После запуска транзакции через BeginTx мы создаём свежий BookStore, у которого в поле db лежит sql.Tx. Вот здесь и окупается подготовка с DBTX: sql.Tx удовлетворяет DBTX, поэтому новое хранилище работает с теми же самыми методами Get, Create и CreateAuditLog. Колбэк получает это транзакционное хранилище, и каждый запрос внутри колбэка проходит через транзакцию. Если колбэк возвращает ошибку, мы делаем rollback. Если нет — commit.

Вызывающий код ни разу не трогает sql.Tx.

Используем Tx в RegisterBook

Когда в интерфейсе появился Tx, RegisterBook может атомарно создать книгу и запись в audit log. Он вызывает s.store.Tx, и всё внутри колбэка проходит через транзакционное хранилище:

// book/service.go

func (s *Service) RegisterBook(
   ctx context.Context, title string) (Book, error) {

   var book Book

   err := s.store.Tx(ctx, func(tx Store) error {
       id, err := tx.Create(ctx, Book{Title: title})
       if err != nil {
           return err
       }
       book = Book{ID: id, Title: title}
       return tx.CreateAuditLog(ctx,
           AuditEntry{BookID: id, Action: "created"})
   })

   return book, err
}

И tx.Create, и tx.CreateAuditLog выполняются в одном и том же sql.Tx. Если любой из вызовов падает, колбэк возвращает ошибку, а Tx откатывает обе записи. Если оба проходят успешно, Tx коммитит их вместе. RegisterBook не видит ни sql.Tx, ни *sql.DB, ни чего-либо ещё из database/sql.

Тестируем транзакции в одном хранилище

Теперь in-memory-хранилищу тоже нужен метод Tx. Поскольку настоящей базы здесь нет, он просто вызывает функцию напрямую, передавая в неё себя:

// book/service_test.go

func (m *memStore) Tx(
   ctx context.Context, fn func(Store) error) error {
   return fn(m)
}

Этого достаточно, чтобы тестировать сервисную логику: вызывает ли RegisterBook и Create, и CreateAuditLog, а также правильно ли он обрабатывает ошибки.

Для интеграционных тестов, которые проверяют реальное поведение commit/rollback на уровне базы, используйте настоящую БД:

// sqlite/store_test.go

func TestTx_RollsBackOnError(t *testing.T) {
   db := setupTestDB(t)

   base := NewBookStore(db)
   failing := &failingStore{BookStore: base}

   svc := book.NewService(failing)

   _, err := svc.RegisterBook(t.Context(), "DDIA")
   if err == nil {
       t.Fatal("expected error")
   }

   var count int
   err = db.QueryRow("SELECT COUNT(*) FROM books").Scan(&count)
   if err != nil {
       t.Fatal(err)
   }
   if count != 0 {
       t.Fatalf("expected 0 books after rollback, got %d", count)
   }
}

failingStore встраивает настоящий SQLite BookStore, но переопределяет CreateAuditLog так, чтобы он всегда возвращал ошибку. Последовательность такая: Tx открывает транзакцию, Create вставляет книгу внутри этой транзакции, CreateAuditLog падает, Tx делает rollback, и таблица books остаётся пустой.

Юнит-тесты с fake-реализациями быстро покрывают сервисную логику. Интеграционные тесты с настоящей базой проверяют транзакционное поведение. Благодаря интерфейсу оба варианта работают с одним и тем же сервисным кодом.

Почему не передавать транзакцию через context?

Изначально xinoiP предлагал положить *sql.Tx в context и заставить хранилище проверять, есть ли она там:

// Не делайте так.
func (s *BookStore) Create(ctx context.Context, b Book) (int64, error) {
   var executor DBTX
   if tx, ok := TxFromContext(ctx); ok {
       executor = tx
   } else {
       executor = s.db
   }
   // ...
}

Такой вариант работает, но перед вызовом хранилища сервису пришлось бы сделать что-то вроде ctx = WithTx(ctx, tx). А значит, сервис знает о существовании SQL-транзакции. Это ровно та связность, от которой интерфейс должен был защищать.

Есть и другая проблема. Значения в context не типизированы статически и не видны явно. Если кто-то забудет положить транзакцию в context или положит её не в тот context, хранилище молча перейдёт на пул соединений, и операции уже не будут атомарными. В подходе с колбэком транзакционное хранилище передаётся как аргумент функции. Это не поймает все ошибки — можно всё ещё случайно вызвать s.store вместо tx для одной из нескольких операций, — но промахнуться так сложнее, чем с невидимым значением в context.

С колбэком сервис говорит: «выполни эти операции атомарно», а хранилище само решает как. Поменяйте завтра Postgres на DynamoDB — сервисный код не изменится.

Транзакции между несколькими репозиториями

Tx на уровне отдельного хранилища из предыдущих разделов работает, когда все записи идут через один и тот же Store. И Create, и CreateAuditLog находятся в Store, поэтому метод Tx одного хранилища может обернуть их в одну транзакцию.

Но доменная модель растёт. Допустим, книжный магазин теперь учитывает остатки и обрабатывает заказы. У книг появляется поле Stock, добавляется новый тип Order и новый интерфейс Store для запросов, связанных с заказами. У каждого хранилища всё ещё есть свой Tx:

// book/book.go

type Store interface {
   Get(ctx context.Context, id int64) (Book, error)
   Create(ctx context.Context, b Book) (int64, error)
   CreateAuditLog(ctx context.Context, e AuditEntry) error
   DecrementStock(ctx context.Context, id int64) error

   Tx(ctx context.Context, fn func(Store) error) error
}

// order/order.go

type Store interface {
   Create(ctx context.Context, o Order) (int64, error)
   Get(ctx context.Context, id int64) (Order, error)

   Tx(ctx context.Context, fn func(Store) error) error
}

DecrementStock уменьшает остаток книги на единицу. В checkout-флоу нужно вызвать DecrementStock у book.Store и Create у order.Store, причём обе операции должны либо закоммититься вместе, либо вместе откатиться. Если остаток уменьшился, а вставка заказа упала, вы потеряли единицу товара без соответствующего заказа.

Можно попробовать вложить колбэки:

// Так не сработает.
err := s.books.Tx(ctx, func(txBooks book.Store) error {
   if err := txBooks.DecrementStock(ctx, bookID); err != nil {
       return err
   }
   return s.orders.Tx(ctx, func(txOrders order.Store) error {
       _, err := txOrders.Create(ctx, order.Order{BookID: bookID})
       return err
   })
})

Это скомпилируется, но books.Tx откроет один sql.Tx для хранилища книг, а orders.Tx — второй, независимый sql.Tx для хранилища заказов. Если вставка заказа упадёт, транзакция заказов откатится, но уменьшение остатка уже будет закоммичено в первой транзакции.

Каждое хранилище умеет создать только транзакционную копию самого себя. Нужен кто-то, кто сможет построить все хранилища из одного sql.Tx.

Unit of Work

Нам нужен координатор, который открывает одну транзакцию в базе и строит из неё все хранилища. Мартин Фаулер называл этот паттерн Unit of Work:

Unit of Work отслеживает всё, что вы делаете во время бизнес-транзакции и что может повлиять на базу данных. Когда вы заканчиваете, он определяет, что нужно изменить в базе по итогам этой работы.

В исходной формулировке Фаулера Unit of Work отслеживает изменённые объекты в памяти и сбрасывает их в базу одной транзакцией. ORM вроде Hibernate реализуют паттерн именно так. В Go нам не нужно отслеживать объекты: наши хранилища уже знают, как писать в базу. Достаточно открыть один sql.Tx, построить из него все хранилища и передать их в колбэк.

Раз управление транзакциями теперь принадлежит Unit of Work, можно убрать Tx из обоих интерфейсов хранилищ. Хранилища снова становятся чистым слоем доступа к данным:

// book/book.go

type Store interface {
   Get(ctx context.Context, id int64) (Book, error)
   Create(ctx context.Context, b Book) (int64, error)
   CreateAuditLog(ctx context.Context, e AuditEntry) error
   DecrementStock(ctx context.Context, id int64) error
}

// order/order.go

type Store interface {
   Create(ctx context.Context, o Order) (int64, error)
   Get(ctx context.Context, id int64) (Order, error)
}

Структура Stores группирует все репозитории, а интерфейс UnitOfWork даёт единственный метод RunInTx, который заменяет Tx на уровне отдельных хранилищ:

// checkout/checkout.go

type Stores struct {
   Books  book.Store
   Orders order.Store
}

type UnitOfWork interface {
   // RunInTx выполняет fn внутри одной транзакции. Каждое хранилище
   // в значении Stores работает поверх этой транзакции.
   RunInTx(ctx context.Context, fn func(Stores) error) error
}

Stores — обычная структура с теми же интерфейсами, от которых уже зависит сервис. По мере роста домена в неё добавляются новые поля.

Реализация для SQLite открывает одну транзакцию и строит из неё оба хранилища:

// sqlite/store.go

type UoW struct{ db *sql.DB }

func NewUoW(db *sql.DB) *UoW { return &UoW{db: db} }

func (u *UoW) RunInTx(
   ctx context.Context,
   fn func(checkout.Stores) error) error {

   tx, err := u.db.BeginTx(ctx, nil)
   if err != nil {
       return err
   }
   defer tx.Rollback() // после Commit ничего не делает

   stores := checkout.Stores{
       Books:  NewBookStore(tx),
       Orders: NewOrderStore(tx),
   }

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

Тот же трюк с DBTX, что и раньше. И NewBookStore(tx), и NewOrderStore(tx) принимают DBTX, а *sql.Tx удовлетворяет DBTX. Оба хранилища работают в одной транзакции. Когда колбэк возвращается, либо коммитится всё, либо откатывается всё.

Используем RunInTx в сервисе

Теперь сервис использует для транзакций UnitOfWork вместо Tx на уровне отдельных хранилищ, поэтому меняются его зависимости. Для нетранзакционных чтений он принимает Stores, а для атомарных записей — UnitOfWork:

// checkout/checkout.go

type Service struct {
   stores Stores
   uow    UnitOfWork
}

func NewService(s Stores, uow UnitOfWork) *Service {
   return &Service{stores: s, uow: uow}
}

PlaceOrder читает книгу вне транзакции — нет смысла держать блокировку ради чтения, — а затем использует RunInTx для двух записей, которые должны быть атомарными:

// checkout/checkout.go

func (s *Service) PlaceOrder(
   ctx context.Context, bookID int64) (order.Order, error) {

   book, err := s.stores.Books.Get(ctx, bookID)
   if err != nil {
       return order.Order{}, err
   }

   var ord order.Order
   err = s.uow.RunInTx(ctx, func(tx Stores) error {
       if err := tx.Books.DecrementStock(ctx, book.ID); err != nil {
           return err
       }
       id, err := tx.Orders.Create(ctx, order.Order{BookID: book.ID})
       if err != nil {
           return err
       }
       ord = order.Order{ID: id, BookID: book.ID}
       return nil
   })

   return ord, err
}

Внутри колбэка и tx.Books, и tx.Orders выполняются в одном и том же sql.Tx. Если DecrementStock проходит успешно, а Orders.Create падает, вся транзакция откатывается, и уменьшение остатка тоже отменяется.

Операции с одним хранилищем работают так же. RegisterBook проходит через RunInTx и использует только tx.Books, игнорируя tx.Orders:

// checkout/checkout.go

func (s *Service) RegisterBook(
   ctx context.Context, title string) (book.Book, error) {

   var b book.Book

   err := s.uow.RunInTx(ctx, func(tx Stores) error {
       id, err := tx.Books.Create(ctx, book.Book{Title: title})
       if err != nil {
           return err
       }
       b = book.Book{ID: id, Title: title}
       return tx.Books.CreateAuditLog(ctx,
           book.AuditEntry{BookID: id, Action: "created"})
   })

   return b, err
}

Когда у вас уже есть Unit of Work, держать Tx в каждом отдельном хранилище больше не нужно. RunInTx закрывает и транзакции внутри одного хранилища, и транзакции между несколькими хранилищами.

Тестируем транзакции между хранилищами

Для юнит-тестов in-memory Unit of Work просто передаёт хранилища дальше:

// checkout/checkout_test.go

type memUoW struct {
   stores Stores
}

func (m *memUoW) RunInTx(
   _ context.Context, fn func(Stores) error) error {
   return fn(m.stores)
}

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

// sqlite/store_test.go

func TestRunInTx_RollsBackOnError(t *testing.T) {
   db := setupTestDB(t)
   bookID := seedBook(t, db, "DDIA", 5)

   stores := checkout.Stores{
       Books:  NewBookStore(db),
       Orders: NewOrderStore(db),
   }
   failUoW := &failingOrderUoW{db: db}
   svc := checkout.NewService(stores, failUoW)

   _, err := svc.PlaceOrder(t.Context(), bookID)
   if err == nil {
       t.Fatal("expected error")
   }

   // Остаток должен остаться прежним, потому что транзакция откатилась.
   var stock int
   err = db.QueryRow(
       "SELECT stock FROM books WHERE id = ?",
       bookID).Scan(&stock)
   if err != nil {
       t.Fatal(err)
   }
   if stock != 5 {
       t.Fatalf("stock = %d, want 5", stock)
   }
}

failingOrderUoW — это UnitOfWork, у которого Store для заказов всегда падает на Create. Он открывает настоящий sql.Tx, строит из него оба хранилища, подменяя хранилище заказов на падающее, и откатывает транзакцию, когда колбэк возвращает ошибку:

// sqlite/store_test.go

type failingOrderUoW struct{ db *sql.DB }

func (u *failingOrderUoW) RunInTx(
   ctx context.Context,
   fn func(checkout.Stores) error) error {

   tx, err := u.db.BeginTx(ctx, nil)
   if err != nil {
       return err
   }
   defer tx.Rollback() // после Commit ничего не делает

   stores := checkout.Stores{
       Books:  NewBookStore(tx),
       Orders: &failingOrderStore{},
   }

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

type failingOrderStore struct{}

func (f *failingOrderStore) Create(
   _ context.Context, _ order.Order) (int64, error) {
   return 0, sql.ErrConnDone
}

func (f *failingOrderStore) Get(
   _ context.Context, _ int64) (order.Order, error) {
   return order.Order{}, sql.ErrConnDone
}

DecrementStock выполнился внутри транзакции и изменил остаток, но из-за падения вставки заказа вся транзакция откатилась, и остаток снова равен 5.

Не слишком ли много абстракции для Go?

Да. Всегда ли я так делаю? Нет.

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

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


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

  • 8 июля в 20:00. «Чистая архитектура на Go без „карго-культа“: слои, DTO и интерфейсы». Записаться

  • 20 июля в 20:00. «Как перестать писать на Java внутри Go-проекта». Записаться

Полный список бесплатных уроков июня смотрите в дайджесте.