Clean Architecture
Правило зависимостей, четыре слоя Uncle Bob и Screaming Architecture. Как связаны Clean, Hexagonal и Onion. Роль DI. Где что размещать в реальном Python-микросервисе.
TL;DR
- Clean Architecture — не «правильный ответ», а формулировка одного семейства подходов. Hexagonal и Onion — про то же самое.
- Единственное неотчуждаемое — правило зависимостей: стрелки направлены к центру.
- Screaming Architecture часто важнее слоёв: структура папок должна кричать о бизнесе, а не о фреймворке.
- Без Dependency Injection всё это не работает: use case не может знать реализацию.
История: одна идея, три названия
В 2005 году Alistair Cockburn опубликовал “Hexagonal Architecture” — попытку структурно решить проблему тесной связи бизнес-логики с UI и БД. Ports & Adapters — вот и весь его словарь. Никаких «слоёв».
В 2008-м Jeffrey Palermo в серии постов “The Onion Architecture” переформулировал идею как концентрические слои с зависимостями к центру. Domain — в самой сердцевине, инфраструктура — снаружи.
В 2012-м Robert Martin в посте “The Clean Architecture” синтезировал Hexagonal, Onion, DCI (Cope) и BCE (Jacobson) — представил четырёхслойную схему с use cases в центре.
The ideas presented in all of them are the same — following the same principles, sharing the same goals. Their differences are semantic: they use different names to describe the same things.
Все три — про одно: изолировать бизнес-логику от деталей реализации (framework, БД, транспорт), чтобы менять детали независимо.
Правило зависимостей
Зависимости в исходном коде должны быть направлены только внутрь — от внешних слоёв к внутренним. Внутренний слой не знает ничего о внешнем.
Это единственное правило, которое не обсуждается. Всё остальное — организация слоёв, куда положить Port, сколько DI-контейнеров — вкусовщина и trade-offs.
Стрелки в исходном коде: всегда внутрь. Стрелки во время выполнения (runtime): могут идти наружу (через DI внешние адаптеры «вставляются» в интерфейсы внутренних). Это и есть Dependency Inversion.
Четыре слоя Uncle Bob
Entities. Бизнес-правила уровня всего предприятия. Не путать с «сущностями БД». Order, у которого метод apply_discount(percent) проверяет инвариант «скидка ≤ 50%». Если завтра меняем систему — Entities остаются.
Use Cases. Application-specific business rules. Оркестрация: получить Order, применить операцию, сохранить. Один use case = один бизнес-сценарий. PlaceOrder, CancelOrder, RefundOrder.
Interface Adapters. Контроллеры, presenters, gateways, реализации репозиториев. Переводят данные из формата use case в формат внешнего мира и обратно.
Frameworks & Drivers. БД, веб-фреймворк (FastAPI, Django), очередь (RabbitMQ), кэш (Redis). Всё, что вы не написали сами.
Что чего не знает:
- Entities не знают про Use Cases.
- Use Cases не знают про Interface Adapters.
- Interface Adapters не знают про Frameworks (в идеале — свой Repository оборачивает драйвер БД).
- Frameworks не знают ни о ком.
Screaming Architecture
Your architectures should scream about the use cases of the application, and not about the frameworks you used.
Открываете папку проекта — что видите первым?
Плохо (framework-centric):
├── controllers/
├── models/
├── serializers/
├── views/
├── urls/
Хорошо (business-centric):
├── orders/
│ ├── place_order/
│ ├── cancel_order/
│ └── refund_order/
├── payments/
├── shipping/
Первая структура кричит: «это Django/Rails-проект». Вторая: «это система обработки заказов». Второе — важнее для команды.
Как связаны Clean, Hexagonal и Onion
| Аспект | Hexagonal | Onion | Clean |
|---|---|---|---|
| Единица разграничения | Port | Слой | Слой |
| Центр | Application | Domain | Use Case |
| Явно про DI | Нет | Да | Да |
| Явно про папочную структуру | Нет | Да | Да |
| Ключевое понятие | Adapter | Dependency Rule | Use Case |
Практическая разница между тремя подходами — небольшая. Разница риторическая:
- Hexagonal акцентирует интерфейс между core и всем остальным.
- Onion акцентирует domain как ядро.
- Clean акцентирует use case как единицу приложения.
Комбинация «Hexagonal-слой + DDD tactical внутри» — самый частый вариант в живых проектах.
DI как основа
Без Dependency Injection ничего из этого не работает.
Логика: use case зависит от RepositoryPort (абстракция). Реальный PostgresRepository реализует Port. Use case не должен знать про Postgres — значит, кто-то должен связать интерфейс с реализацией. Этот кто-то — composition root.
Три способа:
- Manual composition root. Функция при старте создаёт все зависимости и передаёт в конструкторы. Идеально прозрачно, но громоздко на 50+ классов.
- DI-контейнер. Библиотека автоматически связывает по типам. Экономит код, скрывает граф зависимостей — trade-off.
- Service Locator. Классы сами достают зависимости из глобального реестра. Антипаттерн (см. ниже).
Как правильно: пример организации
Пример структуры для FastAPI-микросервиса, ориентированный на один bounded context:
app/
├── domain/ # Слой Entities
│ ├── order.py # Aggregate Order + бизнес-инварианты
│ ├── money.py # Value Object
│ └── errors.py # Доменные исключения
│
├── application/ # Слой Use Cases + Ports
│ ├── ports/
│ │ ├── order_repository.py # Protocol
│ │ ├── payment_gateway.py # Protocol
│ │ └── event_publisher.py # Protocol
│ └── use_cases/
│ ├── place_order.py
│ ├── cancel_order.py
│ └── refund_order.py
│
├── infrastructure/ # Слой Interface Adapters
│ ├── db/
│ │ ├── models.py # SQLAlchemy ORM
│ │ ├── postgres_order_repository.py
│ │ └── mappers.py # domain <-> ORM
│ ├── payment/
│ │ └── stripe_gateway.py
│ └── messaging/
│ └── rabbit_event_publisher.py
│
├── interface/ # Слой Frameworks & Drivers
│ ├── http/
│ │ ├── routes/
│ │ └── schemas.py # Pydantic DTO
│ └── amqp/
│ └── handlers.py
│
└── composition_root.py # DI-конфиг: Port → Adapter
Правило: чем глубже в дереве — тем стабильнее модуль. domain/ меняется медленнее всего, interface/ — быстрее всего.
Как правильно: Port через Python Protocol
Structural subtyping (PEP 544) — идиоматичный способ определить Port:
from typing import Protocol
from app.domain.order import Order, OrderId
class OrderRepositoryPort(Protocol):
async def find_by_id(self, order_id: OrderId) -> Order | None: ...
async def save(self, order: Order) -> None: ...
Что здесь хорошо:
- Port живёт в
application/, потому что use case диктует контракт. - Через
Protocol— не требует наследования.PostgresOrderRepositoryне знает, что реализует Port. Инверсия чистая: клиент определяет интерфейс, реализация — просто удовлетворяет структурно. - Работает и как type-hint, и как runtime-проверка (с
@runtime_checkable).
▸ Domain или Application для Port?
Спор старый. Аргумент за Domain: Port — часть контракта домена, «что нужно домену от инфраструктуры». Аргумент за Application: Port определён use case’ом, use case и должен владеть.
Практически: если Port очень доменный (InventoryReservationPort, где Inventory — доменное понятие) — Domain. Если Port инфраструктурный (EmailSenderPort) — Application. Оба варианта совместимы с правилом зависимостей.
Как правильно: composition root
from dishka import Provider, Scope, provide, make_async_container
# ... импорты
class AppProvider(Provider):
scope = Scope.APP
@provide
def get_engine(self) -> AsyncEngine:
return create_async_engine(settings.pg_dsn, poolclass=NullPool)
@provide
def get_order_repo(
self, session_factory: async_sessionmaker[AsyncSession]
) -> OrderRepositoryPort:
return PostgresOrderRepository(session_factory)
@provide
def get_place_order(
self,
repo: OrderRepositoryPort,
gw: PaymentGatewayPort,
pub: EventPublisherPort,
) -> PlaceOrderUseCase:
return PlaceOrderUseCase(repo=repo, gateway=gw, publisher=pub)
Здесь Port → Adapter собраны явно. Use case запрашивает Port, контейнер подставляет реализацию. Меняем PostgresOrderRepository на InMemoryOrderRepository в тестах — не трогаем use case.
Практика: разложите код по слоям
Практика
Разложите код по слоям
Захватите карточку и перетащите в один из четырёх слоёв. Затем нажмите «Проверить».
class Order:
def apply_discount(self, percent: Decimal) -> None:
if percent > 50:
raise DomainError("discount too big")class OrderStandardizeUseCase:
def __init__(self, normalizer: NormalizerPort, repo: ItemRepositoryPort):
...
async def execute(self, msg: OrderItemMessage) -> None: ...class ItemRepositoryPort(Protocol):
async def find_by_id(self, item_id: int) -> Item | None: ...
async def save(self, item: Item) -> None: ...self._client = httpx.AsyncClient(
timeout=60.0,
limits=httpx.Limits(max_connections=100),
)@broker.subscriber(queue, exchange)
async def handle_order_item(
message: OrderItemMessage,
use_case: FromDishka[OrderStandardizeUseCase],
) -> None:
await use_case.execute(message)@dataclass(frozen=True)
class Money:
amount: Decimal
currency: Currency
def __add__(self, other: Money) -> Money:
if self.currency != other.currency:
raise DomainError()
return Money(self.amount + other.amount, self.currency)class PostgresItemRepository(ItemRepositoryPort):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]):
self._session_factory = session_factory
async def save(self, item: Item) -> None:
async with self._session_factory() as session:
...@app.post("/orders")
async def create_order(
body: CreateOrderRequest,
use_case: FromDishka[CreateOrderUseCase],
) -> CreateOrderResponse:
return await use_case.execute(body.to_command())class OutgoingMessageMapper:
"""Приводит StandardizedItem к формату matcher-service."""
def to_matcher_format(self, item: StandardizedItem) -> dict:
return {"raw": {...}, "values": [{...}]}class ShippingPolicy:
"""Как считается стоимость доставки в зависимости от веса и региона."""
def calculate(self, order: Order, region: Region) -> Money:
...Ядро бизнес-правил. Не знает про фреймворки, БД, HTTP. Entities, Value Objects, Aggregates, Domain Services.
Пусто. Перетащите сюда.
Оркестрация бизнес-сценариев. Use Cases и Ports (интерфейсы для внешних систем). Не знает про UI и БД конкретно.
Пусто. Перетащите сюда.
Адаптеры к внешнему миру: БД, HTTP-клиенты, брокеры, ORM, файловая система.
Пусто. Перетащите сюда.
Точки входа: HTTP-контроллеры, message handlers, CLI, GraphQL resolvers. Транспортный слой.
Пусто. Перетащите сюда.
Как не надо
Domain зависит от ORM
from sqlalchemy.orm import Mapped, mapped_column
from app.infrastructure.db.base import Base
class Order(Base):
__tablename__ = "orders"
id: Mapped[int] = mapped_column(primary_key=True)
total: Mapped[Decimal]
def apply_discount(self, percent: Decimal) -> None:
self.total *= (1 - percent / 100)
Order унаследован от Base — доменная сущность теперь зависит от SQLAlchemy. Правило зависимостей нарушено: entity знает про infrastructure.
class Order(Base):
__tablename__ = "orders"
id: Mapped[int] = mapped_column(primary_key=True)
total: Mapped[Decimal]
def apply_discount(self, percent):
self.total *= (1 - percent / 100) # domain/order.py
@dataclass
class Order:
id: OrderId
total: Money
def apply_discount(self, percent: Decimal) -> None:
if percent > 50:
raise DiscountTooBig()
self.total = self.total * (1 - percent / 100)
# infrastructure/db/mappers.py
def to_orm(order: Order) -> OrderRow: ...
def to_domain(row: OrderRow) -> Order: ... Use case знает про транспорт
class PlaceOrderUseCase:
async def execute(self, request: Request) -> Response:
body = await request.json()
...
return JSONResponse({"ok": True}, status_code=201)
Use case теперь зависит от FastAPI (Request, JSONResponse). Тестировать без ASGI-сервера нельзя. Замена FastAPI на что-то ещё требует правки всех use case’ов.
Правильно: use case принимает PlaceOrderCommand (dataclass), возвращает PlaceOrderResult. HTTP-адаптер сам конвертирует.
Service Locator вместо DI
class PlaceOrderUseCase:
async def execute(self, command):
repo = ServiceLocator.get(OrderRepositoryPort)
gw = ServiceLocator.get(PaymentGatewayPort)
...
Проблемы:
- Зависимости невидимы в сигнатуре — конструктор врёт.
- Тесты требуют настраивать глобальный реестр.
- Реестр — глобальное состояние. Гонки, порядок инициализации.
Service Locator is an anti-pattern. It hides class dependencies, making it easy to violate the Dependency Inversion Principle.
Правильно: явный конструктор (__init__(self, repo, gw, publisher)). Дороже на 30 секунд, окупается годами.
Presenter в domain
class Order:
def to_json(self) -> dict:
return {"id": self.id, "total": str(self.total)}
Сериализация в JSON — задача внешнего слоя. Order теперь знает про JSON. Захотите YAML — поменяете сущность. to_json — типичная утечка presenter’а в entity.
Правильно: отдельный OrderResponseSchema (Pydantic) в interface/http/schemas.py. Маппинг — в контроллере.
Trade-offs
| Ситуация | Стоит применять | Не стоит |
|---|---|---|
| Долгоживущий сервис с растущей командой | ✓ | |
| Разные транспорты (HTTP + AMQP + gRPC) в одном сервисе | ✓ | |
| CRUD-микросервис, 3 таблицы, никогда не меняется | ✓ (Layered хватит) | |
| Прототип, «завтра переписывать» | ✓ | |
| Домен с нетривиальной бизнес-логикой | ✓ | |
| Скрипт на неделю | ✓ |
Правило большого пальца: если время жизни сервиса > 6 месяцев, а количество use case’ов > 5 — Clean окупается.
В твоём же коде
Разбор анонимизированного микросервиса.
Позитив 1. Port через Protocol. Все Port’ы определены как Protocol, реализации не наследуются. PostgresItemRepository удовлетворяет ItemRepositoryPort структурно — как и предусмотрено PEP 544.
Позитив 2. Composition root на Dishka. AppProvider содержит все bindings port→adapter в одном месте. Use case запрашивает Port’ы через __init__. Граф зависимостей явный.
Наблюдение. Пустой domain/. Формально anemic model — все объекты приходят как Pydantic-модели из RabbitMQ, обрабатываются use case’ом, отправляются дальше. Для сервиса-оркестратора без собственных бизнес-инвариантов это приемлемо. Но признак роста: как только появляется правило вроде «нельзя нормализовать больше 100 характеристик за раз», оно окажется либо в use case (быстро), либо породит domain-service (правильно).
Негатив. Transport-специфичный маппер в use case. _adapt_for_consumer(std_attr, raw_attr) знает формат matcher-service. Это утечка контракта Interface Adapter’а в Application-слой. Симптом — статический метод, который не пользуется self. Разбор — см. «Разбор реального проекта» → mapper-in-use-case.
Полный разбор с рефакторингом — глава Repository Pattern, где мы также показываем, как эту логику выносить в OutgoingMessageMapper.
Дальнейшее чтение
- Robert C. Martin. Clean Architecture: A Craftsman’s Guide to Software Structure and Design. Prentice Hall, 2017. Части III–V — про правило зависимостей и Screaming Architecture.
- Robert C. Martin. The Clean Architecture. Blog, 2012 — blog.cleancoder.com.
- Alistair Cockburn. Hexagonal Architecture. 2005 — alistair.cockburn.us.
- Jeffrey Palermo. The Onion Architecture. Blog series, 2008–2012 — jeffreypalermo.com.
- Herberto Graça. Explicit Architecture — DDD, Hexagonal, Onion, Clean, CQRS, … how I put it all together. 2017 — herbertograca.com.
- Tom Hombergs. Get Your Hands Dirty on Clean Architecture. Leanpub, 2019. Java-примеры, но идеи универсальные.
- Vaughn Vernon. Implementing Domain-Driven Design. Addison-Wesley, 2013. Глава 4 — про архитектуру.
- Mark Seemann. Dependency Injection Principles, Practices, and Patterns. Manning, 2019. Критика Service Locator и вводит понятие Composition Root.
Проверьте себя
Мини-quiz · закрепить
Проверьте себя
Q1. Что такое правило зависимостей?
Q2. Что такое Screaming Architecture?
Q3. Правильно ли положить SQLAlchemy Mapped-модель в качестве Entity в domain-слой?
Q4. Что делает Service Locator антипаттерном?
Q5. В каком случае Clean Architecture НЕ окупается?