Лекция 14. Репозиторий, Unit of Work и Dependency Injection
Введение
На прошлых занятиях мы говорили о принципах SOLID и о том, как проектировать гибкие классы. Сегодня соберём эти идеи в три прикладных паттерна, которые встречаются почти в каждом серьёзном приложении:
- Repository — прячет работу с хранилищем данных за простым интерфейсом;
- Unit of Work — собирает несколько операций в одну согласованную транзакцию;
- Dependency Injection (DI) — задаёт способ соединять объекты так, чтобы они не зависели от конкретных реализаций друг друга.
Все три тесно связаны с принципом инверсии зависимостей (буква D в SOLID). Их объединяет общая цель: отделить бизнес-логику от технических деталей (базы данных, файлов, сети) и сделать код тестируемым.
1. Паттерн Repository
1.1. Зачем нужен репозиторий
Представьте, что бизнес-логика напрямую пишет SQL-запросы. Тогда любое изменение схемы БД или переход на другое хранилище заставит переписывать половину приложения. Repository решает эту проблему: он предоставляет объект, который выглядит как коллекция объектов в памяти, а на самом деле скрывает за собой базу данных, файл или внешний API.
Репозиторий отвечает за один тип сущности (продукты, заказы, пользователи) и предоставляет операции: найти, сохранить, удалить.
1.2. Интерфейс репозитория
Сначала описываем абстракцию — что репозиторий умеет, не уточняя как:
from abc import ABC, abstractmethodfrom typing import Optional, List, Dictfrom dataclasses import dataclass
@dataclassclass Product: id: Optional[int] name: str price: float category: str
class ProductRepository(ABC): """Контракт хранилища продуктов"""
@abstractmethod def find_by_id(self, product_id: int) -> Optional[Product]: ...
@abstractmethod def find_all(self) -> List[Product]: ...
@abstractmethod def save(self, product: Product) -> Product: ...
@abstractmethod def delete(self, product_id: int) -> None: ...
@abstractmethod def exists(self, product_id: int) -> bool: ...1.3. Реализация в памяти
Самая простая реализация хранит объекты в словаре. Она удобна для прототипов и тестов:
class InMemoryProductRepository(ProductRepository): def __init__(self): self._storage: Dict[int, Product] = {} self._next_id = 1
def find_by_id(self, product_id: int) -> Optional[Product]: return self._storage.get(product_id)
def find_all(self) -> List[Product]: return list(self._storage.values())
def save(self, product: Product) -> Product: if product.id is None: product.id = self._next_id self._next_id += 1 self._storage[product.id] = product return product
def delete(self, product_id: int) -> None: self._storage.pop(product_id, None)
def exists(self, product_id: int) -> bool: return product_id in self._storage1.4. Реализация на базе данных
Тот же интерфейс можно реализовать поверх настоящей БД. Бизнес-логика об
этом даже не узнает — она работает только с типом ProductRepository:
class DatabaseProductRepository(ProductRepository): """Репозиторий продуктов с доступом к БД (имитация)"""
def __init__(self, connection_string: str): self.connection_string = connection_string # В реальности здесь было бы подключение к БД
def find_by_id(self, product_id: int) -> Optional[Product]: print(f"[DB] SELECT * FROM products WHERE id = {product_id}") # ... выполнение запроса и маппинг строки в объект Product return None
def save(self, product: Product) -> Product: if product.id is None: print("[DB] INSERT INTO products (...) VALUES (...)") else: print(f"[DB] UPDATE products SET ... WHERE id = {product.id}") return product
# find_all, delete, exists реализуются аналогично — через SQL ...1.5. Мок для тестов
Поскольку все реализации подчиняются одному интерфейсу, для тестов легко сделать заглушку (мок), которая возвращает заранее подготовленные данные и фиксирует факт вызова:
class FakeProductRepository(ProductRepository): """Мок: контролируемое поведение для unit-тестов"""
def __init__(self, preset: List[Product] = None): self._items = {p.id: p for p in (preset or [])} self.saved: List[Product] = [] # запоминаем, что сохраняли
def find_by_id(self, product_id): return self._items.get(product_id)
def save(self, product): self.saved.append(product) self._items[product.id] = product return product
def find_all(self): return list(self._items.values())
def delete(self, product_id): self._items.pop(product_id, None)
def exists(self, product_id): return product_id in self._itemsВ тесте мы передаём FakeProductRepository вместо настоящего и проверяем
поведение бизнес-логики, не поднимая базу данных.
1.6. Преимущества Repository
- Абстракция от источника данных — легко переключаться между БД, файлами, API.
- Тестируемость — мок подменяет реальное хранилище.
- Централизация — вся работа с данными собрана в одном месте.
2. Unit of Work
2.1. Проблема согласованности
Часто одна бизнес-операция меняет несколько объектов: создаёт заказ, уменьшает остатки товара, списывает бонусы. Если первое сохранилось, а второе упало с ошибкой — данные станут противоречивыми.
Unit of Work («единица работы») отслеживает все изменения в рамках
одной операции и применяет их единым блоком: либо всё сохраняется
(commit), либо ничего (rollback). По сути это объект, управляющий
транзакцией.
2.2. Интерфейс и реализация
class UnitOfWork(ABC): @abstractmethod def commit(self) -> None: """Применить все накопленные изменения""" ...
@abstractmethod def rollback(self) -> None: """Отменить все накопленные изменения""" ...
class ProductUnitOfWork(UnitOfWork): def __init__(self, repository: ProductRepository): self.repository = repository self._new: List[Product] = [] self._modified: List[Product] = [] self._deleted: List[int] = []
def register_new(self, product: Product) -> None: self._new.append(product)
def register_modified(self, product: Product) -> None: if product not in self._modified: self._modified.append(product)
def register_deleted(self, product_id: int) -> None: if product_id not in self._deleted: self._deleted.append(product_id)
def commit(self) -> None: try: for product in self._new: self.repository.save(product) for product in self._modified: self.repository.save(product) for product_id in self._deleted: self.repository.delete(product_id) self._clear() print("Изменения успешно сохранены") except Exception: self.rollback() raise
def rollback(self) -> None: self._clear() print("Изменения откачены")
def _clear(self) -> None: self._new.clear() self._modified.clear() self._deleted.clear()2.3. Использование
def demo_unit_of_work(): repo = InMemoryProductRepository() uow = ProductUnitOfWork(repo)
uow.register_new(Product(None, "Ноутбук", 50000, "Электроника"))
mouse = repo.save(Product(None, "Мышь", 1000, "Электроника")) mouse.price = 1200 uow.register_modified(mouse)
uow.register_deleted(999) # удаление по id
uow.commit() # всё применяется одной «транзакцией»Если внутри commit возникнет ошибка, вызовется rollback, и накопленные
изменения не попадут в хранилище. В связке с настоящей БД commit/rollback
оборачивают реальную транзакцию SQL.
3. Dependency Injection
3.1. Проблема жёстких зависимостей
Посмотрим на класс, который сам создаёт свои зависимости:
# ДО: жёсткая зависимостьclass UserService: def __init__(self): self.repository = InMemoryUserRepository() # жёстко зашито
def get_user(self, user_id: int): return self.repository.find_by_id(user_id)Проблемы такого подхода:
- нельзя подставить другую реализацию репозитория (БД вместо памяти);
- нельзя протестировать сервис с моком — внутри всегда настоящий объект;
- нарушен принцип инверсии зависимостей: высокоуровневый сервис привязан к конкретному низкоуровневому классу.
3.2. Внедрение через конструктор
Решение — передавать зависимость снаружи, а не создавать внутри:
# ПОСЛЕ: зависимость внедряется через конструкторclass UserService: def __init__(self, repository: UserRepository): # зависим от абстракции self.repository = repository
def get_user(self, user_id: int): return self.repository.find_by_id(user_id)
# Сборка приложения сама решает, какую реализацию подставить:service = UserService(InMemoryUserRepository()) # в проде — БДservice_for_test = UserService(FakeUserRepository()) # в тесте — мокСервис теперь зависит от интерфейса UserRepository, а не от конкретного
класса. Кто и как создаёт репозиторий — забота вызывающего кода.
3.3. Связь с SOLID-D (инверсия зависимостей)
Принцип инверсии зависимостей гласит: модули верхнего уровня не должны зависеть от модулей нижнего уровня; и те, и другие зависят от абстракций.
DI — это практический механизм исполнения этого принципа. Стрелка
зависимости «переворачивается»: вместо того чтобы UserService указывал
на InMemoryUserRepository, оба теперь указывают на абстракцию
UserRepository. Конкретную реализацию подключают на этапе сборки
приложения — в одной точке, которую называют composition root.
3.4. Другие виды внедрения
Помимо конструктора, встречаются:
- Setter Injection — зависимость задаётся методом-сеттером уже после создания объекта (удобно для необязательных зависимостей);
- Interface Injection — объект реализует специальный интерфейс с методом приёма зависимости.
class UserService: def __init__(self): self._repository: Optional[UserRepository] = None
def set_repository(self, repository: UserRepository) -> None: self._repository = repositoryНа практике в Python чаще всего используют именно внедрение через
конструктор: зависимости явно видны в сигнатуре __init__, и объект не
может оказаться в «полусобранном» состоянии.
4. DI-контейнер
4.1. Зачем нужен контейнер
Когда зависимостей много, ручная сборка превращается в длинную цепочку вызовов конструкторов. DI-контейнер (или IoC-контейнер) автоматизирует это: ему один раз сообщают, какая реализация соответствует какому интерфейсу (регистрация), а затем просят выдать готовый объект со всеми вложенными зависимостями (разрешение).
4.2. Простой контейнер
import inspectfrom typing import Dict, Type, Any, Callable
class ServiceContainer: """Минимальный IoC-контейнер"""
def __init__(self): self._implementations: Dict[Type, Type] = {} self._singletons: Dict[Type, Any] = {} self._factories: Dict[Type, Callable] = {}
# --- регистрация --- def register_singleton(self, service_type: Type, instance: Any) -> None: self._singletons[service_type] = instance
def register_factory(self, service_type: Type, factory: Callable) -> None: self._factories[service_type] = factory
def register(self, service_type: Type, implementation: Type) -> None: self._implementations[service_type] = implementation
# --- разрешение --- def resolve(self, service_type: Type) -> Any: if service_type in self._singletons: return self._singletons[service_type] if service_type in self._factories: return self._factories[service_type]() if service_type in self._implementations: return self._create(self._implementations[service_type]) return self._create(service_type)
def _create(self, cls: Type) -> Any: """Создать объект, рекурсивно разрешив параметры конструктора""" sig = inspect.signature(cls.__init__) kwargs = {} for name, param in sig.parameters.items(): if name == "self": continue if param.annotation is not inspect.Parameter.empty: kwargs[name] = self.resolve(param.annotation) elif param.default is not inspect.Parameter.empty: kwargs[name] = param.default return cls(**kwargs)4.3. Использование контейнера
def demo_container(): container = ServiceContainer()
# Репозиторий — один на всё приложение (singleton) container.register_singleton(UserRepository, InMemoryUserRepository())
# Сервис создаётся по требованию, зависимости подставит контейнер container.register(UserService, UserService)
service = container.resolve(UserService) # контейнер прочитал аннотацию repository: UserRepository # и автоматически подставил зарегистрированный singleton print(service.repository)Контейнер смотрит на аннотацию repository: UserRepository в конструкторе
UserService, находит зарегистрированную реализацию и подставляет её. Так
вся «проводка» зависимостей описывается в одном месте, а классы остаются
чистыми. Промышленные библиотеки (например, dependency-injector) делают
то же самое, но с управлением временем жизни, конфигурацией и областями
видимости.
Краткие итоги
- Repository скрывает источник данных за интерфейсом «коллекции объектов»; одна и та же бизнес-логика работает поверх памяти, БД или мока.
- Unit of Work объединяет несколько изменений в одну согласованную
транзакцию с операциями
commitиrollback. - Dependency Injection избавляет классы от создания собственных зависимостей: их передают снаружи, чаще всего через конструктор.
- DI — практическая реализация принципа инверсии зависимостей (SOLID-D): классы зависят от абстракций, а конкретику подключают в composition root.
- DI-контейнер автоматизирует регистрацию и разрешение зависимостей, избавляя от ручной сборки длинных цепочек объектов.
- Вместе эти паттерны дают слабую связанность, тестируемость и гибкость: реализации меняются без правки бизнес-логики.
Вопросы для самопроверки
- Какую задачу решает паттерн Repository и почему бизнес-логике лучше зависеть от его интерфейса, а не от конкретной реализации?
- Чем реализация репозитория в памяти удобна для тестирования?
- Что произойдёт в
ProductUnitOfWork.commit, если при сохранении одного из объектов возникнет исключение? - Перечислите проблемы класса, который сам создаёт свои зависимости внутри конструктора.
- Как Dependency Injection связано с принципом инверсии зависимостей из SOLID?
- Чем внедрение через конструктор отличается от внедрения через сеттер и почему первое обычно предпочтительнее?
- Что означают этапы «регистрация» и «разрешение» в работе DI-контейнера?
- Каким образом контейнер из примера определяет, какие объекты подставить в конструктор сервиса?