[feat] add guides
This commit is contained in:
parent
1fbf77f879
commit
c5d5e243c1
7 changed files with 836 additions and 43 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -10,3 +10,7 @@ wheels/
|
|||
|
||||
# Virtual environments
|
||||
.venv
|
||||
|
||||
!docs
|
||||
!AGENTS.md
|
||||
!tasks.md
|
||||
|
|
|
|||
85
README.md
Normal file
85
README.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
Это шаблон Python-сервиса на чистой архитектуре с заменяемым web-слоем, типизированным конфигом, явным dependency wiring и observability через порты.
|
||||
|
||||
## Что это за проект
|
||||
|
||||
- Небольшой референсный сервис со слоями `domain/`, `usecase/`, `repository/` и `adapter/`
|
||||
- Шаблон для сервисов на FastAPI, где FastAPI остается только во внешнем HTTP adapter
|
||||
- Проект, где конфиг собирается из `config/app.yaml`, `.env` и env vars в одно дерево dataclass-конфигов
|
||||
- Проект, где repository и usecase создаются один раз на старте приложения в composition root
|
||||
- Проект, где логи, метрики и трейсы скрыты за интерфейсами и могут работать через `stdout`, файл или OpenTelemetry runtime
|
||||
|
||||
## Основные идеи
|
||||
|
||||
- Clean Architecture и границы SOLID
|
||||
- Направление зависимостей только внутрь
|
||||
- Тонкие adapter-слои и явная сборка зависимостей
|
||||
- Заменяемый HTTP-слой
|
||||
- Observability без протекания OpenTelemetry во внутренние слои
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
```bash
|
||||
make install
|
||||
APP_API_TOKEN=local-api-token APP_SIGNING_KEY=local-signing-key make run
|
||||
```
|
||||
|
||||
Приложение стартует на `http://0.0.0.0:8123` и публикует versioned API под `/api/v1`.
|
||||
|
||||
## Документация
|
||||
|
||||
### Гайды
|
||||
|
||||
- [Правила проекта и ограничения для агента](AGENTS.md)
|
||||
- [Кодстайл проекта для AI-агента](docs/CODESTYLE.md)
|
||||
- [Чистая архитектура, SOLID, DIP, Protocol и repository](docs/CLEAN_ARCHITECTURE_RU.md)
|
||||
- [Логи, метрики и трейсы в этом проекте](docs/OBSERVABILITY_RU.md)
|
||||
- [Как чистая архитектура реализована здесь](docs/PROJECT_GUIDE_RU.md)
|
||||
- [План задач и история работ](tasks.md)
|
||||
|
||||
### ADR
|
||||
|
||||
- [001 Composition Root and Lifetimes](docs/001-composition-root-and-lifetimes.md)
|
||||
- [002 Config From YAML and Env](docs/002-config-yaml-plus-env.md)
|
||||
- [003 Observability Via Interfaces](docs/003-observability-via-interfaces.md)
|
||||
- [004 Versioned HTTP API](docs/004-versioned-http-api.md)
|
||||
- [005 Early FastAPI OTel Instrumentation](docs/005-fastapi-otel-early-instrumentation.md)
|
||||
|
||||
## Структура проекта
|
||||
|
||||
- `domain/` - core-сущности и доменные ошибки
|
||||
- `usecase/` - прикладные сценарии и порты
|
||||
- `repository/` - реализации repository
|
||||
- `adapter/config/` - загрузка и модели типизированного конфига
|
||||
- `adapter/observability/` - выбор runtime для logger, metrics и tracer
|
||||
- `adapter/otel/` - OpenTelemetry adapters
|
||||
- `adapter/di/` - composition root и singleton wiring
|
||||
- `adapter/http/fastapi/` - HTTP-схемы, dependencies, middleware и routers
|
||||
- `config/` - YAML-конфиг приложения и локального OTel collector
|
||||
|
||||
## Для ИИ
|
||||
|
||||
Если ты AI-агент и собираешься что-то менять в проекте, сначала прочитай документы в таком порядке:
|
||||
|
||||
1. [Правила проекта и ограничения агента](AGENTS.md) - обязательные правила работы в этом репозитории
|
||||
2. [Кодстайл проекта для AI-агента](docs/CODESTYLE.md) - границы слоев, стиль кода и правила зависимостей
|
||||
3. [Как чистая архитектура реализована здесь](docs/PROJECT_GUIDE_RU.md) - практическая карта проекта и типовые сценарии изменений
|
||||
4. [Чистая архитектура, SOLID, DIP, Protocol и repository](docs/CLEAN_ARCHITECTURE_RU.md) - базовые архитектурные принципы и примеры
|
||||
5. [Логи, метрики и трейсы в этом проекте](docs/OBSERVABILITY_RU.md) - читать перед любыми изменениями в observability, middleware и runtime wiring
|
||||
6. [ADR в `docs/`](docs/001-composition-root-and-lifetimes.md) - читать релевантные решения перед изменением архитектуры или startup wiring
|
||||
7. [План задач и история работ](tasks.md) - понять, что уже сделано, что отложено и какие ограничения были зафиксированы
|
||||
|
||||
Перед началом работы:
|
||||
|
||||
- Определи, в каком слое будет изменение: `domain/`, `usecase/`, `repository/` или `adapter/`
|
||||
- Убедись, что зависимости идут только внутрь
|
||||
- Не тащи FastAPI и OpenTelemetry во внутренние слои
|
||||
- Сначала изучи существующий код в нужной директории, потом вноси изменения
|
||||
- Если задача затрагивает архитектурное решение, сначала сверяйся с ADR и проектными правилами
|
||||
|
||||
## Запуск и команды
|
||||
|
||||
- Для локального запуска нужны `APP_API_TOKEN` и `APP_SIGNING_KEY`
|
||||
- `make run` запускает приложение локально
|
||||
- `make run-otel` запускает приложение с локальными OTel endpoints из env vars
|
||||
- `make pre-commit` запускает `ruff`, `mypy` и `pytest`
|
||||
- `make compose-up` поднимает приложение и локальный LGTM stack через Docker Compose
|
||||
227
docs/CLEAN_ARCHITECTURE_RU.md
Normal file
227
docs/CLEAN_ARCHITECTURE_RU.md
Normal 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
83
docs/CODESTYLE.md
Normal 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
178
docs/OBSERVABILITY_RU.md
Normal 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
216
docs/PROJECT_GUIDE_RU.md
Normal 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`
|
||||
86
uv.lock
generated
86
uv.lock
generated
|
|
@ -242,6 +242,49 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "master"
|
||||
version = "0.0.1"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-exporter-otlp-proto-http" },
|
||||
{ name = "opentelemetry-instrumentation-fastapi" },
|
||||
{ name = "opentelemetry-sdk" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "mypy" },
|
||||
{ name = "pytest" },
|
||||
{ name = "ruff" },
|
||||
{ name = "types-pyyaml" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.116.1" },
|
||||
{ name = "opentelemetry-api", specifier = ">=1.31.1" },
|
||||
{ name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.31.1" },
|
||||
{ name = "opentelemetry-instrumentation-fastapi", specifier = ">=0.52b1" },
|
||||
{ name = "opentelemetry-sdk", specifier = ">=1.31.1" },
|
||||
{ name = "python-dotenv", specifier = ">=1.2.2" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.2" },
|
||||
{ name = "uvicorn", specifier = ">=0.35.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "mypy", specifier = ">=1.18.2" },
|
||||
{ name = "pytest", specifier = ">=8.4.2" },
|
||||
{ name = "ruff", specifier = ">=0.13.1" },
|
||||
{ name = "types-pyyaml", specifier = ">=6.0.12.20250915" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
version = "1.19.1"
|
||||
|
|
@ -700,49 +743,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-python-skelet"
|
||||
version = "0.0.1"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-exporter-otlp-proto-http" },
|
||||
{ name = "opentelemetry-instrumentation-fastapi" },
|
||||
{ name = "opentelemetry-sdk" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "mypy" },
|
||||
{ name = "pytest" },
|
||||
{ name = "ruff" },
|
||||
{ name = "types-pyyaml" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.116.1" },
|
||||
{ name = "opentelemetry-api", specifier = ">=1.31.1" },
|
||||
{ name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.31.1" },
|
||||
{ name = "opentelemetry-instrumentation-fastapi", specifier = ">=0.52b1" },
|
||||
{ name = "opentelemetry-sdk", specifier = ">=1.31.1" },
|
||||
{ name = "python-dotenv", specifier = ">=1.2.2" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.2" },
|
||||
{ name = "uvicorn", specifier = ">=0.35.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "mypy", specifier = ">=1.18.2" },
|
||||
{ name = "pytest", specifier = ">=8.4.2" },
|
||||
{ name = "ruff", specifier = ">=0.13.1" },
|
||||
{ name = "types-pyyaml", specifier = ">=6.0.12.20250915" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wrapt"
|
||||
version = "1.17.3"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue