Уровень 2 · Стили Глава 06 13 мин

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.

Herberto Graça Explicit Architecture, 2017

Все три — про одно: изолировать бизнес-логику от деталей реализации (framework, БД, транспорт), чтобы менять детали независимо.

Правило зависимостей

Определение Dependency Rule (правило зависимостей)

Зависимости в исходном коде должны быть направлены только внутрь — от внешних слоёв к внутренним. Внутренний слой не знает ничего о внешнем.

— Martin, Clean Architecture, 2017

Это единственное правило, которое не обсуждается. Всё остальное — организация слоёв, куда положить 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.

Robert C. Martin Screaming Architecture, 2011

Открываете папку проекта — что видите первым?

Плохо (framework-centric):

├── controllers/
├── models/
├── serializers/
├── views/
├── urls/

Хорошо (business-centric):

├── orders/
│   ├── place_order/
│   ├── cancel_order/
│   └── refund_order/
├── payments/
├── shipping/

Первая структура кричит: «это Django/Rails-проект». Вторая: «это система обработки заказов». Второе — важнее для команды.

Как связаны Clean, Hexagonal и Onion

АспектHexagonalOnionClean
Единица разграниченияPortСлойСлой
ЦентрApplicationDomainUse Case
Явно про DIНетДаДа
Явно про папочную структуруНетДаДа
Ключевое понятиеAdapterDependency RuleUse 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.

Практика: разложите код по слоям

Практика

Разложите код по слоям

Захватите карточку и перетащите в один из четырёх слоёв. Затем нажмите «Проверить».

0/10размещено
00Ещё не расставлены10
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:
        ...
01Domain0

Ядро бизнес-правил. Не знает про фреймворки, БД, HTTP. Entities, Value Objects, Aggregates, Domain Services.

Пусто. Перетащите сюда.

02Application0

Оркестрация бизнес-сценариев. Use Cases и Ports (интерфейсы для внешних систем). Не знает про UI и БД конкретно.

Пусто. Перетащите сюда.

03Infrastructure0

Адаптеры к внешнему миру: БД, HTTP-клиенты, брокеры, ORM, файловая система.

Пусто. Перетащите сюда.

04Interface0

Точки входа: 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.

Domain знает ORM
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 изолирован
# 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)      
        ...

Проблемы:

  1. Зависимости невидимы в сигнатуре — конструктор врёт.
  2. Тесты требуют настраивать глобальный реестр.
  3. Реестр — глобальное состояние. Гонки, порядок инициализации.

Service Locator is an anti-pattern. It hides class dependencies, making it easy to violate the Dependency Inversion Principle.

Mark Seemann Dependency Injection Principles, Practices, and Patterns, 2019

Правильно: явный конструктор (__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 · закрепить

Проверьте себя

  1. Q1. Что такое правило зависимостей?

  2. Q2. Что такое Screaming Architecture?

  3. Q3. Правильно ли положить SQLAlchemy Mapped-модель в качестве Entity в domain-слой?

  4. Q4. Что делает Service Locator антипаттерном?

  5. Q5. В каком случае Clean Architecture НЕ окупается?