惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

S
Schneier on Security
F
Fortinet All Blogs
B
Blog
GbyAI
GbyAI
P
Proofpoint News Feed
量子位
The Register - Security
The Register - Security
宝玉的分享
宝玉的分享
大猫的无限游戏
大猫的无限游戏
云风的 BLOG
云风的 BLOG
V
Visual Studio Blog
B
Blog RSS Feed
WordPress大学
WordPress大学
Recorded Future
Recorded Future
Recent Announcements
Recent Announcements
V
Vulnerabilities – Threatpost
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
S
Secure Thoughts
雷峰网
雷峰网
Stack Overflow Blog
Stack Overflow Blog
C
Cybersecurity and Infrastructure Security Agency CISA
Webroot Blog
Webroot Blog
AWS News Blog
AWS News Blog
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
The GitHub Blog
The GitHub Blog
爱范儿
爱范儿
O
OpenAI News
月光博客
月光博客
H
Hacker News: Front Page
S
Security Affairs
W
WeLiveSecurity
The Hacker News
The Hacker News
aimingoo的专栏
aimingoo的专栏
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
Help Net Security
Help Net Security
MongoDB | Blog
MongoDB | Blog
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
D
Docker
T
The Blog of Author Tim Ferriss
Spread Privacy
Spread Privacy
Blog — PlanetScale
Blog — PlanetScale
J
Java Code Geeks
S
Securelist
Microsoft Azure Blog
Microsoft Azure Blog
TaoSecurity Blog
TaoSecurity Blog
T
Threat Research - Cisco Blogs
M
MIT News - Artificial intelligence
A
About on SuperTechFans

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Unit of Work в Go: практический гайд по транзакциям между репозиториями
Ксения Мосеенкова · 2026-06-24 · via Все публикации подряд на Хабре

Средний

14 мин

32

Эта статья выросла из пары быстрых ответов на вопросы на 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-проекта». Записаться

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