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

83
docs/CODESTYLE.md Normal file
View file

@ -0,0 +1,83 @@
# Code Style Guide
## Purpose
This repository is a Python clean architecture template.
Write code that keeps the core portable, adapters thin, and wiring explicit.
## Layer rules
- `domain/` contains entities and domain errors only
- `usecase/` contains application logic and ports
- `repository/` contains repository implementations
- `adapter/` contains framework, config, DI, HTTP, and observability integrations
- Dependencies must point inward
- `domain/` must not import anything from project internals
- `usecase/` may import `domain/`, but must not import `adapter/`, `repository/`, FastAPI, or OpenTelemetry
- `repository/` and `adapter/` may depend on `usecase/` ports and `domain/`
## Dependency inversion
- Define interfaces as `Protocol` types at the point of use
- Keep implementations in outer layers
- Inject dependencies through constructors
- Prefer explicit wiring in `adapter/di/container.py`
- Do not hide object creation behind magic globals
## Python conventions
- Use simple names
- Prefer dataclasses for immutable values, config sections, and request models inside inner layers
- Keep `__init__.py` files empty
- Do not use `from __future__ import annotations`
- Prefer one clear responsibility per class
- Keep functions small and direct
## Comments and errors
- Add comments only when the code is not obvious
- Keep comments short
- Do not end comments with a period
- Keep error messages short
- Do not end error messages with a period
## HTTP rules
- Keep FastAPI code inside `adapter/http/fastapi/`
- Keep HTTP request and response schemas inside the HTTP adapter
- Handlers should translate HTTP input to usecase calls and map domain errors to HTTP responses
- Do not move business logic into routers, dependencies, or middleware
## Observability rules
- Inner layers know only `Logger`, `Metrics`, and `Tracer` ports
- OpenTelemetry code stays in `adapter/otel/`
- Use `Noop` implementations when a signal is disabled
- Request logging belongs in the HTTP adapter
## Configuration rules
- Keep non-secret defaults in `config/app.yaml`
- Read secrets from env vars
- Merge YAML, `.env`, and process env into one typed config tree
- Do not read env vars directly inside `domain/` or `usecase/`
## Example
```py
from typing import Protocol
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
```
The use case owns the contract.
The repository implementation lives outside the use case layer.

178
docs/OBSERVABILITY_RU.md Normal file
View file

@ -0,0 +1,178 @@
# Логи, метрики и трейсы
## Зачем нужны три сигнала
### Логи
Логи отвечают на вопрос: что произошло.
Примеры:
- пришел HTTP-запрос
- пользователь не найден
- сервис стартовал
### Метрики
Метрики отвечают на вопрос: как часто и насколько долго что-то происходит.
Примеры:
- сколько было запросов
- сколько было ошибок
- сколько миллисекунд заняла операция
### Трейсы
Трейсы отвечают на вопрос: как прошел путь одного конкретного запроса через систему.
Примеры:
- HTTP request -> use case -> repository
- где именно замедление
- на каком шаге произошла ошибка
## Как observability устроена в этом проекте
Внутренние слои не знают про OpenTelemetry напрямую.
Они работают только через порты из `usecase/interface.py`:
- `Logger`
- `Metrics`
- `Tracer`
- `Span`
Такой подход сохраняет чистую архитектуру: use case зависит от абстракций, а не от SDK.
## Где лежат реализации
### Логирование
- `adapter/observability/logging.py` - логирование в `stdout` или файл
- `adapter/otel/logging.py` - логирование через OpenTelemetry exporter
Поддерживаются режимы:
- `stdout`
- `file`
- `otel`
Поддерживаются форматы:
- `text`
- `json`
### Метрики
- `adapter/otel/metrics.py` - адаптер над OTel `Meter`
Метрики создаются лениво: счетчик или гистограмма заводятся при первом обращении по имени.
### Трейсинг
- `adapter/otel/tracing.py` - адаптер над OTel `Tracer`
Use case или repository может открыть span через `with tracer.start_span(...):`.
### Runtime factory
- `adapter/observability/factory.py` - выбирает, какой runtime собрать
- `adapter/observability/noop.py` - noop-реализации, когда сигнал отключен
- `adapter/otel/bootstrap.py` - создает OTel providers и exporters
## Как это работает на старте приложения
1. Загружается `AppConfig`
2. `build_observability(config)` выбирает нужные реализации
3. Если хотя бы один сигнал должен идти через OTel, создается OTel runtime
4. В контейнер кладутся готовые `logger`, `metrics`, `tracer`
5. Use case и repository получают их через конструктор
## Как observability работает в HTTP
### Request logging
В `adapter/http/fastapi/middleware.py` есть HTTP middleware, которое:
- замеряет длительность запроса
- фиксирует метод, путь и статус
- пишет событие `http_request`
### HTTP traces и HTTP metrics
В `adapter/http/fastapi/app.py` вызывается `FastAPIInstrumentor.instrument_app(...)`.
Это дает стандартную OTel instrumentation для FastAPI/ASGI без кастомного trace middleware.
## Как пользоваться логгером в use case
Пример:
```py
class CreateUser:
def __init__(self, logger: Logger) -> None:
self._logger = logger
def execute(self, email: str) -> None:
self._logger.info('user_create', attrs={'user.email': email})
```
Рекомендации:
- message короткий и стабильный
- важные поля передавать в `attrs`
- не шить JSON руками в строку сообщения
## Как пользоваться метриками
Пример:
```py
class CreateUser:
def __init__(self, metrics: Metrics) -> None:
self._metrics = metrics
def execute(self, email: str) -> None:
self._metrics.increment('user.create.total', attrs={'source': 'api'})
self._metrics.record('user.create.duration_ms', 12.5)
```
Практика именования:
- счетчики: `something.total`, `something.error`
- длительности: `something.duration_ms`
- атрибуты делать короткими и стабильными
## Как пользоваться трейсами
Пример:
```py
class CreateUser:
def __init__(self, tracer: Tracer) -> None:
self._tracer = tracer
def execute(self, user_id: str) -> None:
with self._tracer.start_span('usecase.create_user', attrs={'user.id': user_id}) as span:
span.set_attribute('user.flow', 'signup')
```
Если внутри возникнет ошибка, адаптер трейсинга сможет записать ее в span.
## Как включать и выключать сигналы
В `config/app.yaml` и env есть основные флаги:
- `logging.output=stdout|file|otel`
- `logging.format=text|json`
- `logging.file_path=...`
- `metrics.enabled=true|false`
- `tracing.enabled=true|false`
Когда метрики или трейсы выключены, внутренние слои получают `Noop`-реализации.
Это значит, что код use case не приходится усложнять проверками `if enabled`.
## Главное правило
`domain/` и `usecase/` не импортируют OpenTelemetry.
Весь OTel код остается во внешних слоях.

216
docs/PROJECT_GUIDE_RU.md Normal file
View file

@ -0,0 +1,216 @@
# Как эта архитектура реализована в проекте
## Общая схема
Проект собирается так:
1. `adapter/config/loader.py` читает YAML, `.env` и env vars
2. `adapter/di/container.py` строит единый контейнер
3. `adapter/observability/factory.py` создает logger, metrics и tracer runtime
4. repository и usecase создаются один раз при старте
5. `adapter/http/fastapi/app.py` создает FastAPI app и подключает router + middleware
Это и есть composition root: все внешние зависимости собираются в одном месте и потом передаются внутрь.
## Как здесь реализована чистая архитектура
### `domain/`
- хранит сущности и доменные ошибки
- не зависит от framework и SDK
### `usecase/`
- описывает сценарии приложения
- задает интерфейсы через `Protocol`
- работает только с абстракциями
### `repository/`
- реализует интерфейсы доступа к данным
- может использовать tracer, clients, DB drivers
### `adapter/`
- оборачивает внешние инструменты
- переводит HTTP/config/OTel в контракты приложения
## Как создать новый use case
Рабочая последовательность:
1. Если нужна новая бизнес-сущность или ошибка, добавить ее в `domain/`
2. Если use case зависит от внешнего ресурса, описать контракт в `usecase/`
4. Создать класс use case
5. Внедрить зависимости через конструктор
6. Подключить use case в `adapter/di/container.py`
Минимальный шаблон:
```py
from usecase.interface import Logger, Metrics, Tracer, UserRepository
from domain.user import User
class CreateUser:
def __init__(self, repository: UserRepository, logger: Logger, metrics: Metrics, tracer: Tracer) -> None:
self._repository = repository
self._logger = logger
self._metrics = metrics
self._tracer = tracer
def execute(self, email: str) -> User:
with self._tracer.start_span('usecase.create_user', attrs={'user.email': email}):
user = self._repository.save(email)
self._metrics.increment('user.create.total')
self._logger.info('user_create', attrs={'user.email': email})
return user
```
Правила:
- не импортировать FastAPI в use case
- не читать env vars в use case
- не работать с `Request`, `Response`, `HTTPException`
## Как работать с repository
### 1. Сначала описать интерфейс
Контракт должен жить рядом с use case, который его использует.
```py
class UserRepository(Protocol):
def get(self, user_id: str) -> User | None: ...
def save(self, user: User) -> None: ...
```
### 2. Потом сделать реализацию
Реализация уходит во внешний слой, например в `repository/`.
```py
class InMemoryUserRepository(UserRepository):
def __init__(self, tracer: Tracer) -> None:
self._tracer = tracer
self._users: dict[str, User] = {}
```
### 3. Подключить в контейнере
Именно `adapter/di/container.py` решает, какую реализацию дать use case.
Это важно: use case не должен сам выбирать repository.
## Как добавить новую HTTP ручку
Последовательность:
1. Создать или переиспользовать use case
2. При необходимости добавить схемы в `adapter/http/fastapi/schemas.py`
3. Добавить dependency, если нужен новый use case из контейнера
4. Добавить маршрут в `adapter/http/fastapi/routers/v1/router.py`
5. Преобразовать HTTP input в команду или query use case
6. Преобразовать результат use case в response model
7. Смаппить доменные ошибки в `HTTPException`
Шаблон:
```py
@router.post('/users', response_model=UserResponse)
def create_user(payload: CreateUserRequest, usecase: CreateUser = Depends(get_create_user)) -> UserResponse:
try:
user = usecase.execute(payload.email)
except UserConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return UserResponse(id=user.id, email=user.email)
```
Правило: router остается тонким.
## Как пользоваться логгером, метриками и трейсами
### Логгер
- использовать для бизнес-событий и предупреждений
- передавать данные через `attrs`
- сообщения делать короткими и стабильными
### Метрики
- использовать для счетчиков и длительностей
- не создавать условные ветки `if metrics_enabled`
- runtime сам подставит рабочую или noop-реализацию
### Трейсы
- открывать span на границе важной операции
- класть ключевые атрибуты в `attrs`
- ошибки писать через `record_error`, если это делает смысл явным
## Как конфигурировать проект
### Базовая конфигурация
Основной файл - `config/app.yaml`.
Там лежат:
- имя приложения
- окружение
- HTTP host/port
- режим логирования
- флаги метрик и трейсов
- OTel endpoints
- имя заголовка токена
### Секреты
Обязательные секреты берутся из env:
- `APP_API_TOKEN`
- `APP_SIGNING_KEY`
### Переопределение через env
Можно переопределять YAML через переменные окружения, например:
- `APP_HTTP_HOST`
- `APP_HTTP_PORT`
- `APP_LOGGING_LEVEL`
- `APP_LOGGING_OUTPUT`
- `APP_LOGGING_FORMAT`
- `APP_LOGGING_FILE_PATH`
- `APP_METRICS_ENABLED`
- `APP_TRACING_ENABLED`
- `APP_OTEL_LOGS_ENDPOINT`
- `APP_OTEL_METRICS_ENDPOINT`
- `APP_OTEL_TRACES_ENDPOINT`
### Локальный запуск
```bash
APP_API_TOKEN=local-api-token APP_SIGNING_KEY=local-signing-key make run
```
### Запуск с OTel
```bash
APP_API_TOKEN=local-api-token APP_SIGNING_KEY=local-signing-key make run-otel
```
### Docker Compose
```bash
APP_API_TOKEN=local-api-token APP_SIGNING_KEY=local-signing-key make compose-up
```
## Короткая памятка
- новая бизнес-логика -> `usecase/`
- новый контракт -> рядом с use case
- новая реализация контракта -> `repository/` или внешний `adapter/`
- новый HTTP маршрут -> `adapter/http/fastapi/`
- новый infra/runtime code -> `adapter/`
- зависимости связывать только в `adapter/di/container.py`