Единица работы (Unit of Work)
Собирает все изменения бизнес-операции (новые, изменённые и удалённые объекты) и записывает их в хранилище одним проходом в конце, а не по одному в момент каждой правки.
Проблема
Операция в магазине редко трогает один объект: добавить товар Product, снизить цену другому, убрать третий. Если каждый метод сразу пишет в базу, то запись размазана по коду: insert в одном месте, update в другом, delete в третьем. Один и тот же объект, поправленный дважды, уходит в базу дважды. Объект, созданный и тут же удалённый, честно вставляется и удаляется. А если операция падает на середине, половина записей уже сделана, и откатывать их нечем.
Второй вариант не лучше: держать изменения в памяти и в конце вспоминать, что из этого надо сохранить. Это знание тогда живёт в каждой операции отдельно.
Решение
Единица работы UnitOfWork — список дел для хранилища на одну бизнес-операцию. Код операции правит объекты как обычно и только сообщает: этот новый (registerNew), этот изменился (registerDirty), этот удалён (registerDeleted). Писать в базу он не пытается.
commit проходит списки один раз: сначала вставки, потом обновления, потом удаления. Повторные регистрации сворачиваются: изменённый дважды объект обновляется один раз, созданный и удалённый в той же операции до базы не доходит. После commit единица пуста и готова к следующей операции.
В настоящем проекте commit открывает транзакцию, и всё, что накопилось, применяется в ней целиком или не применяется вовсе. В базовом примере транзакции нет: он показывает учёт изменений, а транзакции и откат разобраны в практике.
Структура
Database стоит на месте настоящей базы: хранит копии строк, поэтому правка объекта в памяти не попадает в «базу» сама собой, и пишет каждую операцию в журнал log. По журналу тест видит, что именно и в каком порядке записала единица работы, и что до commit не записано ничего. Product об единице работы не знает: это обычный объект с изменяемыми полями.
Как это собирается
Единицу работы создаёт обработчик запроса: одна операция - одна UnitOfWork. В примере это загрузка прайс-листа. Обработчик importPriceList получает строки файла (номер, название, цена), для каждой находит товар в базе или создаёт новый и только регистрирует изменение: нового товара нет в базе - registerNew, цена 0 - registerDeleted, другая цена - registerDirty. В базу он не пишет ни разу, всё уходит одним commit в конце. В приложении товар загружает репозиторий, а commit открывает транзакцию.
Первый прайс-лист дважды меняет цену стула, снимает стол и добавляет табурет, который ниже в том же файле снят: в базу уходят ровно три записи, insert p-1, update p-2, delete p-3. Во втором прайс-листе отрицательная цена: обработчик отвечает 422 и просто не вызывает commit. Лампа уже успела подешеветь в памяти, но в базе осталась цена 1500, и записей по-прежнему три. Откатывать нечего, потому что до commit ничего не записано.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
В единице работы три списка, и правила между ними — главное, что она знает. registerDirty для объекта, который уже в списке новых, ничего не делает: вставка и так запишет актуальные поля. registerDeleted для нового объекта просто вычёркивает его из новых, а для изменённого переносит из dirty в removed: обновлять строку, которую сейчас удалят, незачем.
| Язык | Особенность |
|---|---|
| PHP | Списки — массивы с ключом-id, поэтому повторная регистрация перезаписывает ключ; Database хранит clone объекта |
| Go | Списки — срезы указателей в порядке регистрации, объект узнаётся по указателю; Database хранит структуры по значению, это и есть копия |
| Python | Списки — dict по id (порядок вставки сохраняется); копия строки — dataclasses.replace |
| TypeScript | Списки — Map по id, commit обходит их forEach; копия строки — новый Product |
Запуск: make test T=patterns/enterprise/unit-of-work
Плюсы и минусы
Тесты смотрят в журнал базы. Первый: до commit журнал пуст, после — ровно insert p-1, update p-2, delete p-3 в этом порядке. Таблица из четырёх случаев проверяет сворачивание повторов: дважды изменённый — одно обновление, новый и изменённый — одна вставка, новый и удалённый — ничего, изменённый и удалённый — одно удаление. Последний тест: второй commit подряд ничего не пишет.
- ✅ Запись в хранилище собрана в одном месте и в одном проходе: проще обернуть в транзакцию, проще пакетировать запросы.
- ✅ Лишние записи отпадают сами: повторные обновления, вставка с последующим удалением.
- ✅ Код операции не знает про
insertиupdate, он работает с объектами. - ❌ Ручная регистрация: забыли
registerDirty— изменение молча потеряно. Поэтому ORM обычно отслеживают изменения сами, сравнивая объект со снимком при загрузке (так сделано в практике). - ❌ Пока не вызван
commit, база и память расходятся: другой код, читающий из базы напрямую, видит старые данные.
Когда не применять
- Операция пишет одну строку одним запросом: список дел из одного пункта ничего не даёт.
- В проекте уже есть ORM с единицей работы (Doctrine
EntityManager, SQLAlchemySession, EFDbContext): писать вторую поверх незачем, надо понимать, как работает имеющаяся. - Массовые операции (
UPDATE ... WHEREна миллион строк): загружать каждую строку в объект ради учёта изменений дорого, такие правки делают запросом.
Отличия между языками
- PHP — массив с ключом-id одновременно и упорядоченный список, и множество: повторная регистрация перезаписывает ключ, порядок первой вставки сохраняется.
cloneвDatabaseнужен, потому что объекты передаются по ссылке. - Go — у
mapнет порядка обхода, а журнал должен быть стабильным, поэтому списки — срезы, а поиск в них —slices.Containsиslices.DeleteFunc. Объект узнаётся по указателю, без id. Копию даёт сама семантика значений:Insert(*p)кладёт в базу структуру, а не указатель. - Python —
dictхранит порядок вставки, поэтому отдельный упорядоченный список не нужен.dataclassбезfrozen: единица работы как раз про изменяемые объекты. - TypeScript —
Mapтоже хранит порядок вставки. Единица по смыслу та же, что в PHP и Python; объект узнаётся поid, а не по ссылке.
Практика
Учебный пример полагается на ручную регистрацию и не показывает ни транзакцию, ни откат, ни того, что один и тот же объект может быть загружен дважды. Практика: перевод и заказ в одной транзакции добавляет хранилище с begin/commit/rollback, identity map (один объект на id в пределах единицы работы) и учёт изменений по снимку: заказ списывает деньги и остатки склада, и ошибка на любой строке откатывает всё.
Связанные темы
- Практика: перевод и заказ в одной транзакции — identity map, учёт изменений по снимку и откат
- Репозиторий (Repository) — отдаёт объекты операции; в паре с единицей работы репозиторий не пишет в базу сам, а сохранение откладывается до
commit. - Преобразователь данных (Data Mapper) — знает, как превратить объект в строку и обратно; единица работы решает, когда и какие строки записать.
- Команда (Command) — тоже копит действия, чтобы выполнить их позже, но команда знает, как себя выполнить, а единица работы хранит объекты и сама решает, какой запрос нужен.
- Снимок (Memento) — снимок состояния, на котором в практике построены откат транзакции и поиск изменённых объектов.