Repository и Unit of Work

Учимся прятать детали хранилища за коллекцию-подобным интерфейсом и фиксировать пачку изменений одной транзакцией.

Repository — объект, который ведёт себя как коллекция доменных сущностей в памяти (get, add, list), скрывая, что под капотом SQL и сетевые запросы. Unit of Work — объект, который копит сделанные за бизнес-операцию изменения и фиксирует их все разом в одной транзакции.

Зачем это нужно на практике

Представьте сервис заказов. Без репозитория код прикладной логики пестрит строками вроде cursor.execute("SELECT ... WHERE id = ?"), ручной сборкой объектов из строк и разбросанными по всему проекту INSERT/UPDATE. Когда вы решите сменить таблицу, добавить кэш или переехать с одной БД на другую, придётся править десятки мест. Repository собирает весь доступ к данным одной сущности в одном классе: прикладной код просит orders.get(101) и orders.add(order), не зная и не желая знать, откуда они берутся.

Unit of Work решает другую боль — целостность. Один бизнес-сценарий (оформить заказ) трогает несколько таблиц: списать товар со склада, создать заказ, уменьшить бонусный счёт. Если на середине что-то упадёт, нельзя оставить склад списанным, а заказ — несозданным. UoW накапливает все эти правки и в конце делает commit() в одной транзакции: либо всё, либо ничего. Прикладной код перестаёт вручную расставлять BEGIN/COMMIT по сценарию.

Repository как коллекция

Ключевая идея: репозиторий притворяется списком объектов, который всегда был у вас в памяти. У него методы предметной области, а не базы данных: не select_by_email, а get_by_email; не row_to_user, а просто отдача готового User. Важная деталь — Identity Map: на одну строку в БД должен приходиться ровно один объект в памяти за время операции. Иначе два чтения той же сущности дадут два разных объекта, и правки в одном не увидит другой.

class User:
    def __init__(self, id, name, email):
        self.id, self.name, self.email = id, name, email
    def __repr__(self):
        return f"User({self.id}, {self.name!r})"

class UserRepository:
    def __init__(self, identity_map):
        self._identity_map = identity_map  # уже загруженные объекты
        self._storage = {                  # имитация таблицы в БД
            1: ("Аня", "[email protected]"),
            2: ("Борис", "[email protected]"),
        }
    def get(self, user_id):
        if user_id in self._identity_map:        # уже в памяти - тот же объект
            return self._identity_map[user_id]
        name, email = self._storage[user_id]     # иначе «читаем из БД»
        user = User(user_id, name, email)
        self._identity_map[user_id] = user
        return user

identity_map = {}
repo = UserRepository(identity_map)
a = repo.get(1)
b = repo.get(1)
print("Один объект на строку:", a is b)
print(a)

Вывод:

Один объект на строку: True
User(1, 'Аня')

Два вызова get(1) вернули один и тот же объект, потому что Identity Map отдала закэшированный экземпляр вместо повторной «загрузки». Это фундамент: на нём строится отслеживание изменений.

Unit of Work и отслеживание изменений

UoW держит три набора: new (вставить), dirty (обновить), removed (удалить). Прикладной код регистрирует в них объекты по ходу сценария, а в финале зовёт commit(), который проигрывает накопленное в правильном порядке и в одной транзакции. Если на любом шаге случается ошибка — rollback() просто очищает наборы, и в БД ничего не уходит.

class UnitOfWork:
    def __init__(self):
        self.new = []        # объекты на вставку
        self.dirty = []      # изменённые
        self.removed = []    # на удаление
    def register_new(self, obj):     self.new.append(obj)
    def register_dirty(self, obj):
        if obj not in self.dirty and obj not in self.new:
            self.dirty.append(obj)
    def register_removed(self, obj): self.removed.append(obj)
    def commit(self):
        for o in self.new:     print(f"INSERT {o}")
        for o in self.dirty:   print(f"UPDATE {o}")
        for o in self.removed: print(f"DELETE {o}")
        print("COMMIT")
        self.new.clear(); self.dirty.clear(); self.removed.clear()
    def rollback(self):
        print("ROLLBACK")
        self.new.clear(); self.dirty.clear(); self.removed.clear()

uow = UnitOfWork()
uow.register_new("Order#101")
uow.register_dirty("User#1")
uow.register_dirty("User#1")   # повторно - не задвоится
uow.register_removed("Cart#7")
uow.commit()

Вывод:

INSERT Order#101
UPDATE User#1
DELETE Cart#7
COMMIT

Обратите внимание: повторная регистрация User#1 как dirty не задвоила UPDATE — UoW дедуплицирует правки. На практике dirty-список обычно заполняется автоматически: репозиторий после чтения снимает «снимок» полей, а перед коммитом сравнивает с текущими значениями и сам решает, что изменилось.

Как это работает под капотом

В зрелых ORM эти два паттерна — несущая конструкция. В Python это особенно наглядно у SQLAlchemy: его Session — это и есть Unit of Work. Когда вы пишете session.add(order) и меняете поля уже загруженных объектов, Session копит правки в своей identity map и сбрасывает их в БД при flush()/commit() одним блоком, в порядке, учитывающем зависимости внешних ключей. А session.query(User) и репозитории поверх него играют роль Repository.

В мире Java тот же расклад: JPA-EntityManager — это Unit of Work, а Spring Data JpaRepository — Repository. Везде один и тот же приём: загруженные объекты живут в identity map, изменения отслеживаются (dirty checking), а синхронизация с БД откладывается до явной фиксации. Понимая это, вы перестаёте удивляться, почему «я же не вызывал save, а в базе всё обновилось» — объект был отслеживаемым, и UoW записал его при коммите.

Частые ошибки

  • Репозиторий-«дырка» в абстракции. Метод get_queryset() или execute(sql), торчащий из репозитория наружу, протаскивает детали БД в прикладной код и убивает весь смысл. Интерфейс должен говорить на языке предметной области.
  • Транзакция на каждый вызов репозитория. Если add() сам открывает и коммитит транзакцию, вы теряете атомарность сценария из нескольких шагов. Границу транзакции задаёт Unit of Work на уровне всей бизнес-операции, а не отдельного метода.
  • Игнор Identity Map. Без него один объект существует в нескольких экземплярах; правки в одной копии теряются, всплывают рассинхронизированные данные и лишние UPDATE.
  • «Универсальный» Generic Repository на всё. Один абстрактный Repository<T> с CRUD на все сущности звучит красиво, но быстро обрастает протечками: у разных агрегатов разные правила доступа. Лучше конкретные репозитории под конкретные нужды.

Итоги

  • Repository прячет доступ к данным за коллекцию-подобный интерфейс на языке предметной области.
  • Unit of Work отслеживает new/dirty/removed и фиксирует их одной транзакцией — «всё или ничего».
  • Identity Map даёт один объект на одну строку, без чего отслеживание изменений ломается.
  • В реальных ORM это Session/EntityManager (UoW) и JpaRepository (Repository) — не экзотика, а ядро.
Проверьте себя
1. Зачем репозиторию нужна Identity Map?
AЧтобы кэшировать SQL-запросы и ускорять выборки
BЧтобы на одну строку в БД приходился ровно один объект в памяти, и правки не терялись
CЧтобы автоматически генерировать первичные ключи
DЧтобы шифровать идентификаторы сущностей
2. Что обеспечивает Unit of Work при commit()?
AПараллельную запись в несколько баз данных
BПрименение всех накопленных изменений в одной транзакции — либо все, либо ни одного
CАвтоматическую валидацию доменных правил
DКэширование результатов на диск
3. Почему торчащий из репозитория метод execute(sql) считается ошибкой?
AОн медленнее, чем ORM
BОн протаскивает детали БД в прикладной код и разрушает абстракцию репозитория
CОн несовместим с транзакциями
DSQL нельзя вызывать из Python