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) — не экзотика, а ядро.