8.1 KiB
Чистая архитектура, 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 здесь такой:
- use case объявляет, что ему нужно
- это описывается через
Protocol - внешняя реализация удовлетворяет протоколу
- 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- сущностьUserdomain/error.py-DomainError,UserNotFoundError,UserConflictError
domain/ не должен знать ни про FastAPI, ни про OTel, ни про YAML, ни про env.
usecase/
Здесь лежат прикладные сценарии.
Use case:
- принимает простые входные данные
- работает через интерфейсы
- возвращает доменные сущности или прикладные результаты
- не знает про HTTP и инфраструктуру
Пример:
usecase/user.py->GetUserusecase/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:
PostgresUserRepositoryRedisSessionRepositoryExternalBillingRepository
adapter/
Это внешний слой.
Здесь находятся:
- FastAPI router и схемы
- загрузка конфига
- DI контейнер
- логирование, метрики, трейсы
- интеграции со сторонними библиотеками
Adapter переводит внешний мир в язык use case и обратно.
Пример разделения ответственности
Хорошая цепочка:
- HTTP handler получает
user_id - handler создает
GetUserQuery - use case вызывает
UserRepository - repository возвращает
User - handler маппит
Userв HTTP response model
Плохая цепочка:
- router сам идет в базу
- router сам открывает span
- router сам собирает config
- router сам решает бизнес-правила
Почему это удобно
- проще тестировать use case отдельно от HTTP и OTel
- проще менять инфраструктуру
- проще читать проект по слоям
- проще давать задачу AI-агенту без риска смешать ответственности