# Чистая архитектура, SOLID и контракты ## Что такое чистая архитектура Чистая архитектура - это способ строить проект слоями так, чтобы бизнес-правила жили отдельно от фреймворков, транспорта, базы данных и внешних SDK. Главная идея: - самые важные правила лежат ближе к центру - внешние детали зависят от внутренних правил - смена FastAPI, базы или OTel не должна ломать `domain/` и `usecase/` В этом проекте центр выглядит так: - `domain/` - сущности и доменные ошибки - `usecase/` - сценарии приложения и интерфейсы - `repository/` - реализации хранилищ - `adapter/` - HTTP, конфиг, DI, observability ## SOLID коротко ### S - Single Responsibility Principle Один класс делает одну работу. Хорошо: - `UserService` только исполняет сценарий CRUD пользователя - `InMemoryUserRepository` только хранит и читает пользователей - `StdoutLogger` только пишет логи в stdout Плохо: - use case читает env - router сам работает с базой - repository знает про HTTP-статусы ### O - Open/Closed Principle Код должен расширяться без переписывания ядра. Пример: можно добавить `PostgresUserRepository`, не меняя `GetUser`, если обе реализации соблюдают один и тот же контракт `UserRepository`. ### L - Liskov Substitution Principle Любая реализация интерфейса должна корректно подменять другую. Если use case ожидает `UserRepository`, ему должно быть все равно, это in-memory репозиторий, PostgreSQL или HTTP client repository. ### I - Interface Segregation Principle Интерфейсы должны быть небольшими и точными. Лучше так: ```py class UserRepository(Protocol): def get(self, user_id: str) -> User | None: ... def get_by_email(self, email: str) -> User | None: ... def save(self, user: User) -> None: ... ``` Чем так: ```py class MegaRepository(Protocol): def get_user(self, user_id: str) -> User | None: ... def save_user(self, user: User) -> None: ... def send_email(self, message: str) -> None: ... def collect_metric(self, name: str) -> None: ... ``` ### D - Dependency Inversion Principle Высокоуровневый код не зависит от низкоуровневых деталей. Use case не должен зависеть от конкретного `InMemoryUserRepository`, `FastAPI`, `OTLPMetricExporter` или `logging.Logger`. Он зависит только от абстракций. ## Как работает инверсия зависимостей Смысл DIP здесь такой: 1. use case объявляет, что ему нужно 2. это описывается через `Protocol` 3. внешняя реализация удовлетворяет протоколу 4. composition root связывает use case с реализацией Пример: ```py class UserRepository(Protocol): def get(self, user_id: str) -> User | None: ... class GetUser: def __init__(self, repository: UserRepository, logger: Logger, tracer: Tracer) -> None: self._repository = repository self._logger = logger self._tracer = tracer ``` `GetUser` знает только о контракте `UserRepository`. Реализация уже подключается снаружи, в `adapter/di/container.py`. ## Что такое Protocol в Python `Protocol` - это способ описать интерфейс через набор методов без жесткого наследования. Если класс имеет нужные методы, он уже подходит под протокол. Это удобно для чистой архитектуры, потому что: - use case задает контракт рядом с местом использования - внешние слои могут реализовать контракт без сильной связки - тестировать проще Пример логгера: ```py class Logger(Protocol): def info(self, message: str, attrs: Attrs | None = None) -> None: ... def warning(self, message: str, attrs: Attrs | None = None) -> None: ... def error(self, message: str, attrs: Attrs | None = None) -> None: ... ``` Пример SRP через протоколы: - `Logger` отвечает только за логирование - `Metrics` только за счетчики и гистограммы - `Tracer` только за спаны ## Архитектура слоев в проекте ### `domain/` Здесь лежат самые базовые вещи: - сущности - value objects, если появятся - доменные ошибки Пример: - `domain/user.py` - сущность `User` - `domain/error.py` - `DomainError`, `UserNotFoundError`, `UserConflictError` `domain/` не должен знать ни про FastAPI, ни про OTel, ни про YAML, ни про env. ### `usecase/` Здесь лежат прикладные сценарии. Use case: - принимает простые входные данные - работает через интерфейсы - возвращает доменные сущности или прикладные результаты - не знает про HTTP и инфраструктуру Пример: - `usecase/user.py` -> `GetUser` - `usecase/interface.py` -> `UserRepository`, `Logger`, `Metrics`, `Tracer` ### `repository/` Здесь живут реализации контрактов доступа к данным. Пример из проекта: ```py class InMemoryUserRepository(UserRepository): def __init__(self, tracer: Tracer, users: Iterable[User] | None = None) -> None: self._users = {user.id: user for user in users or ()} self._tracer = tracer ``` Что важно: - repository может зависеть от `domain/` и `usecase/` - repository не должен тянуть HTTP детали в use case - repository может быть заменен на PostgreSQL, Redis, gRPC client, REST client Еще примеры возможных repository: - `PostgresUserRepository` - `RedisSessionRepository` - `ExternalBillingRepository` ### `adapter/` Это внешний слой. Здесь находятся: - FastAPI router и схемы - загрузка конфига - DI контейнер - логирование, метрики, трейсы - интеграции со сторонними библиотеками Adapter переводит внешний мир в язык use case и обратно. ## Пример разделения ответственности Хорошая цепочка: 1. HTTP handler получает `user_id` 2. handler создает `GetUserQuery` 3. use case вызывает `UserRepository` 4. repository возвращает `User` 5. handler маппит `User` в HTTP response model Плохая цепочка: 1. router сам идет в базу 2. router сам открывает span 3. router сам собирает config 4. router сам решает бизнес-правила ## Почему это удобно - проще тестировать use case отдельно от HTTP и OTel - проще менять инфраструктуру - проще читать проект по слоям - проще давать задачу AI-агенту без риска смешать ответственности