handbookНаписать

patterns/enterprise/unit-of-work/README.md · тема · 4 языка · 27 тестов

Единица работы (Unit of Work) ​

Собирает все изменения бизнес-операции (новые, изменённые и удалённые объекты) и записывает их в хранилище одним проходом в конце, а не по одному в момент каждой правки.

Проблема ​

Операция в магазине редко трогает один объект: добавить товар Product, снизить цену другому, убрать третий. Если каждый метод сразу пишет в базу, то запись размазана по коду: insert в одном месте, update в другом, delete в третьем. Один и тот же объект, поправленный дважды, уходит в базу дважды. Объект, созданный и тут же удалённый, честно вставляется и удаляется. А если операция падает на середине, половина записей уже сделана, и откатывать их нечем.

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

Решение ​

Единица работы UnitOfWork — список дел для хранилища на одну бизнес-операцию. Код операции правит объекты как обычно и только сообщает: этот новый (registerNew), этот изменился (registerDirty), этот удалён (registerDeleted). Писать в базу он не пытается.

commit проходит списки один раз: сначала вставки, потом обновления, потом удаления. Повторные регистрации сворачиваются: изменённый дважды объект обновляется один раз, созданный и удалённый в той же операции до базы не доходит. После commit единица пуста и готова к следующей операции.

В настоящем проекте commit открывает транзакцию, и всё, что накопилось, применяется в ней целиком или не применяется вовсе. В базовом примере транзакции нет: он показывает учёт изменений, а транзакции и откат разобраны в практике.

Структура ​

new, dirty, removed

commit

Product

+id: string

+name: string

+priceCents: int

UnitOfWork

+registerNew(product)

+registerDirty(product)

+registerDeleted(product)

+commit()

Database

+log: string[]

+find(id) : Product

+insert(product)

+update(product)

+delete(id)

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, SQLAlchemy Session, EF DbContext): писать вторую поверх незачем, надо понимать, как работает имеющаяся.
  • Массовые операции (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) — снимок состояния, на котором в практике построены откат транзакции и поиск изменённых объектов.
© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS