handbookНаписать

patterns/enterprise/unit-of-work/practice/README.md · практика · 4 языка · 35 тестов

база: Единица работы

Практика: перевод и заказ в одной транзакции (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.

Структура ​

accounts, stock

Account

+id: string

+balanceCents: int

+withdraw(amountCents)

+deposit(amountCents)

StockItem

+sku: string

+quantity: int

+take(quantity)

InMemoryStore

+log: string[]

+loads: int

+begin()

+commit()

+rollback()

+load(key) : int

+save(key, value)

Tracker<T>

+get(store, id) : T

+saveChanges(store)

UnitOfWork

+account(id) : Account

+stockItem(sku) : StockItem

+commit()

Operations

+transfer(uow, fromId, toId, amountCents)

+placeOrder(uow, customerId, lines, totalCents)

Хранилище 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 в панели кода.

Реализации ​

ЯзыкОсобенность
PHPTracker — обобщённый через @template и замыкания Closure; public private(set) открывает поля для чтения и закрывает для записи; операции — статические методы Operations, потому что classmap не загружает функции
Gotracker[T] на дженериках, порядок записей держит срез ids; ошибки — сентинелы с %w, Commit сам вызывает Rollback и оборачивает ошибку; строки заказа — срез []Line, а не map
Python_Tracker[T] с синтаксисом дженериков 3.12+, класс сам служит фабрикой (_Tracker("accounts", Account, ...)); откат в except с голым raise
TypeScriptTracker<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 30007000, 3000, 0, 5, 1
order changes accounts and stock2 лампы за 40006000, 0, 4000, 3, 1
insufficient funds writes nothing2 лампы за 15000без изменений, InsufficientFunds до commit
constraint on save rolls back everything2 лампы и 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, чтобы он попал в сообщение и стек.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS