Практика: перевод и заказ в одной транзакции (Unit of Work)
Оформление заказа списывает деньги клиента, зачисляет их магазину и уменьшает остатки склада. Единица работы держит все эти объекты, сама находит изменённые и записывает их в одной транзакции: всё или ничего.
Базовый пример: Единица работы. Там изменения регистрируют вручную, транзакции нет, и один и тот же объект можно загрузить дважды. Здесь единица работы сама отслеживает изменения по снимку, выдаёт один объект на id и откатывает транзакцию, если запись упала на середине.
Задача
Небольшой магазин со внутренними счетами: клиент пополняет баланс и платит с него, магазин получает деньги на свой счёт, склад ведёт остатки. Сначала каждая операция писала в базу сама: transfer делал два UPDATE, оформление заказа вызывало transfer и потом обновляло склад. Однажды на складе не хватило кабеля, обновление остатков упало, а деньги уже ушли со счёта клиента на счёт магазина.
Вторая беда нашлась позже. Обработчик «перевести другу и сразу оплатить заказ» загружал счёт клиента дважды: перевод уменьшал баланс в одной копии, заказ проверял баланс по другой, свежей из базы, и пропускал оплату, на которую денег уже не было. Последний UPDATE перезаписывал первый, и перевод терялся.
Что урезано
- База — словарь
"таблица/id" -> числов памяти. Транзакция — снимок словаря вbegin, откат — возврат снимка. Настоящая база делает то же самое через журнал, а не копию. - Ограничение
CHECK (value >= 0)проверяется вsaveхранилища для всех строк сразу: и баланс, и остаток не могут уйти в минус. - У счёта и товара по одному полю, поэтому снимок объекта — одно число. ORM снимает все поля и сравнивает их.
- Цена заказа приходит готовой суммой, магазин — счёт с id
shop. - Нет параллельных единиц работы и блокировок: о них в «Где граница».
Решение
Предметные объекты — Account и StockItem — ничего не знают о хранилище. Счёт сам проверяет, хватает ли денег (withdraw бросает InsufficientFunds), а товар не проверяет остаток: это ограничение базы, и оно сработает при записи.
Операции transfer и placeOrder берут объекты у единицы работы и правят их как обычные объекты. Они не сохраняют и не коммитят: решение о commit принимает тот, кто начал бизнес-операцию. Поэтому две операции можно собрать в одну единицу работы, и они увидят изменения друг друга. В Go, Python и TypeScript операции — обычные функции, в PHP — статические методы класса Operations.
Структура
Хранилище InMemoryStore умеет четыре вещи настоящей базы: читать строку (load считает чтения в loads), писать строку с проверкой ограничения (save бросает ConstraintViolation с ключом и значением), открыть транзакцию снимком и откатить её. Каждое действие попадает в журнал log: по нему тест видит, какие строки успели записаться до отката.
Identity map и учёт изменений
В единице работы на каждую таблицу свой Tracker — identity map: словарь «id -> объект и значение при загрузке». Первый account('ann') читает строку из хранилища и кладёт объект в словарь, второй возвращает тот же объект без чтения. Поэтому перевод и заказ в одной единице работы правят один и тот же счёт, и заказ видит баланс уже после перевода.
Значение при загрузке — это снимок. На commit трекер сравнивает текущее значение объекта со снимком и пишет только изменённые строки: регистрировать изменения вручную, как в базовом примере, не нужно, и забыть это сделать нельзя. Загруженный, но не изменённый счёт до базы не доходит.
commit открывает транзакцию, пишет изменения счетов, потом склада и фиксирует. Любая ошибка записи откатывает транзакцию и уходит наверх той же ошибкой. Ошибка предметной области (InsufficientFunds) случается раньше, до commit, и тогда хранилище вообще не тронуто.
Как это собирается
Единицу работы создаёт не операция, а обёртка запроса handle: в приложении это middleware или декоратор обработчика. Она создаёт UnitOfWork над хранилищем, отдаёт её телу запроса, после успеха вызывает commit и переводит ошибки в ответ: InsufficientFunds - 402, ConstraintViolation - 409. Тела запросов - обычные вызовы transfer и placeOrder, они не знают ни о транзакциях, ни о том, что их можно сложить в одну операцию.
Первый запрос переводит другу 3000 и в той же единице работы оплачивает две лампы: заказ видит баланс уже после перевода, всё записывается одной транзакцией. Второй запрос пытается купить два кабеля при остатке 1: деньги со счёта списаны в памяти, но save склада нарушает ограничение, commit откатывает транзакцию, и баланс bob остаётся 3000. Третий запрос падает на нехватке денег ещё до commit, хранилище не тронуто. Путь вызова: обёртка запроса -> операции -> единица работы -> хранилище.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
| Язык | Особенность |
|---|---|
| PHP | Tracker — обобщённый через @template и замыкания Closure; public private(set) открывает поля для чтения и закрывает для записи; операции — статические методы Operations, потому что classmap не загружает функции |
| Go | tracker[T] на дженериках, порядок записей держит срез ids; ошибки — сентинелы с %w, Commit сам вызывает Rollback и оборачивает ошибку; строки заказа — срез []Line, а не map |
| Python | _Tracker[T] с синтаксисом дженериков 3.12+, класс сам служит фабрикой (_Tracker("accounts", Account, ...)); откат в except с голым raise |
| TypeScript | Tracker<T> не экспортирован: это деталь единицы работы; откат в catch с пробросом той же ошибки |
Запуск: make test T=patterns/enterprise/unit-of-work/practice
Что проверяют тесты
Тесты одинаковы во всех языках. Хранилище в начале: у ann 10000 копеек, у bob и shop по нулю, на складе 5 ламп и 1 кабель. После каждого случая проверяются все пять строк сразу.
| Случай | Действие | ann, bob, shop, lamp, cable |
|---|---|---|
| transfer moves money | перевод ann -> bob 3000 | 7000, 3000, 0, 5, 1 |
| order changes accounts and stock | 2 лампы за 4000 | 6000, 0, 4000, 3, 1 |
| insufficient funds writes nothing | 2 лампы за 15000 | без изменений, InsufficientFunds до commit |
| constraint on save rolls back everything | 2 лампы и 2 кабеля за 5000 | без изменений, ConstraintViolation на кабеле |
| transfer and order share one account | перевод bob 7000, потом заказ на 4000 | без изменений, InsufficientFunds: заказ видит баланс 3000 |
Последний случай — то, ради чего нужен identity map: с двумя копиями счёта заказ увидел бы 10000 и прошёл.
Отдельные тесты: два вызова account('ann') дают один объект и одно чтение из хранилища; загруженный, но не изменённый bob не пишется (журнал begin, save accounts/ann, save accounts/shop, commit); при нехватке кабеля журнал показывает, что счета и лампы успели записаться, а затем rollback вернул всё, и ошибка называет строку и значение: stock/cable: -1 is below zero.
Где граница
- Счета и склад в разных сервисах или базах — одной транзакции больше нет. Двухфазный коммит (XA) между базами медленный, держит блокировки и плохо переживает падение координатора, поэтому его почти не используют. Обычный путь — сага: шаги по очереди, у каждого компенсация (вернуть деньги, вернуть остаток), а надёжная доставка шагов — через outbox в той же транзакции, что и изменения своей базы.
- Две единицы работы правят один счёт параллельно: обе прочитали 10000, обе записали своё, одно списание потерялось. Транзакция этого не ловит. Нужна оптимистическая блокировка (колонка
version,UPDATE ... WHERE version = ?, конфликт — повтор операции) илиSELECT ... FOR UPDATE. - Объектов тысячи: identity map держит их все в памяти до
commit, а сравнение со снимком проходит по каждому. Для массовых правок — запросUPDATEбез загрузки объектов. - Операция трогает одну строку одним запросом — единица работы избыточна.
- В проекте есть ORM: Doctrine, SQLAlchemy, Entity Framework, Hibernate уже делают ровно это (identity map, учёт изменений по снимку, flush в транзакции). Своя единица работы нужна, когда ORM нет или для понимания, что происходит внутри
flush().
Отличия между языками
- PHP — функции не попадают в classmap-автозагрузку, поэтому операции собраны в
final class Operationsсо статическими методами.public private(set)(PHP 8.4) даёт читать баланс снаружи и менять только черезwithdraw/deposit. Массив копируется при присваивании, так что$this->snapshot = $this->rows— уже снимок. - Go — у
mapнет порядка обхода, а журнал и порядок записей должны быть стабильны, поэтому у трекера срезids, а строки заказа — срез[]Line. Исключений нет:Commitпроверяет ошибку каждой записи, откатывает и оборачивает еёcommit: %w, аerrors.Isнаходит сентинел. Снимок —maps.Clone. - Python —
dictхранит порядок вставки,dict(self.rows)— снимок. КлассAccountподходит как фабрика(id, value) -> Accountбез лямбды. Голыйraiseпослеrollback()пробрасывает исходное исключение с трассировкой. - TypeScript —
new Map(this.rows)— снимок. Вcatchошибка имеет типunknownи пробрасывается как есть. Классы ошибок задаютname, чтобы он попал в сообщение и стек.
Связанные темы
- Единица работы (базовый пример)
- Практика фасада: оформление заказа — тот же заказ, но шаги идут через разные сервисы и откатываются компенсацией, а не транзакцией.
- Outbox (Transactional Outbox) — как надёжно сообщить другим сервисам о заказе, записав событие в ту же транзакцию, что и изменения.
- Снимок (Memento) — снимок состояния, на котором здесь построены и откат, и поиск изменённых объектов.