master/docs/CLEAN_ARCHITECTURE_RU.md
2026-03-26 21:56:10 +03:00

227 lines
8.1 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Чистая архитектура, 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-агенту без риска смешать ответственности