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

8.1 KiB
Raw Permalink Blame History

Чистая архитектура, 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

Интерфейсы должны быть небольшими и точными.

Лучше так:

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: ...

Чем так:

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 с реализацией

Пример:

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 задает контракт рядом с местом использования
  • внешние слои могут реализовать контракт без сильной связки
  • тестировать проще

Пример логгера:

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/

Здесь живут реализации контрактов доступа к данным.

Пример из проекта:

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