Перейти к содержимому

Лекция 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, abstractmethod
from typing import Optional, List, Dict
from dataclasses import dataclass
@dataclass
class 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._storage

1.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 inspect
from 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-контейнер автоматизирует регистрацию и разрешение зависимостей, избавляя от ручной сборки длинных цепочек объектов.
  • Вместе эти паттерны дают слабую связанность, тестируемость и гибкость: реализации меняются без правки бизнес-логики.

Вопросы для самопроверки

  1. Какую задачу решает паттерн Repository и почему бизнес-логике лучше зависеть от его интерфейса, а не от конкретной реализации?
  2. Чем реализация репозитория в памяти удобна для тестирования?
  3. Что произойдёт в ProductUnitOfWork.commit, если при сохранении одного из объектов возникнет исключение?
  4. Перечислите проблемы класса, который сам создаёт свои зависимости внутри конструктора.
  5. Как Dependency Injection связано с принципом инверсии зависимостей из SOLID?
  6. Чем внедрение через конструктор отличается от внедрения через сеттер и почему первое обычно предпочтительнее?
  7. Что означают этапы «регистрация» и «разрешение» в работе DI-контейнера?
  8. Каким образом контейнер из примера определяет, какие объекты подставить в конструктор сервиса?