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