[feat] add guides

This commit is contained in:
Azamat 2026-03-26 21:56:10 +03:00
parent 1fbf77f879
commit c5d5e243c1
7 changed files with 836 additions and 43 deletions

View file

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