Архитектурные паттерны Многоуровневая, гексагональная, луковичная и чистая архитектуры
0%

Многоуровневая, гексагональная, луковичная и чистая архитектуры

Многоуровневая, гексагональная, луковичная и чистая архитектуры

Есть вопрос, который решает любая кодовая база размером больше одного файла: где живёт бизнес-логика и от чего она имеет право зависеть. Ответов накопилось четыре популярных — layered, hexagonal, onion, clean. Их регулярно противопоставляют, рисуют схемы с шестиугольниками и кольцами и спорят, чем «чистая» лучше «луковичной».

Правда скучнее и полезнее: это одна идея в четырёх упаковках. Идея называется правило зависимостей — «важное не должно зависеть от неважного». Различаются школы тем, насколько строго они это правило формулируют и какие имена дают участникам. Разобравшись с механикой один раз, вы перестанете выбирать между картинками и начнёте выбирать между конкретными компромиссами.

Симптом: почему вообще понадобились слои

Возьмём типичный обработчик HTTP-запроса, каких в мире написаны миллионы:

@app.post("/orders/{order_id}/place")
def place_order(order_id: int, request: Request):
    conn = psycopg2.connect(DSN)
    row = conn.execute("SELECT * FROM orders WHERE id = %s", (order_id,)).fetchone()
    if row["status"] != "draft":
        return JSONResponse({"error": "already placed"}, status_code=409)
    total = sum(i["price"] * i["qty"] for i in json.loads(row["items"]))
    if total > 100_000 and not row["is_verified"]:
        return JSONResponse({"error": "verification required"}, status_code=403)
    conn.execute("UPDATE orders SET status = 'placed' WHERE id = %s", (order_id,))
    requests.post(SLACK_HOOK, json={"text": f"Новый заказ {order_id}"})
    return {"ok": True}

Код работает. Проблемы начинаются позже и все — про связность решений разной скорости изменения:

  1. Правило «заказ больше 100 000 требует верификации» нельзя найти. Оно размазано по HTTP-контроллеру. Через год таких правил тридцать, и живут они в двенадцати контроллерах, четырёх cron-скриптах и одной админке.
  2. Нельзя протестировать без Postgres и без сети. Тест бизнес-правила требует поднять БД, замокать Slack и сформировать HTTP-запрос. Стоимость проверки одной строчки логики — секунды вместо микросекунд.
  3. Нельзя переиспользовать. Приехала задача «то же самое, но из очереди Kafka» — придётся копировать.
  4. Смена технологии = переписывание логики. Миграция с psycopg на асинхронный драйвер трогает файлы, в которых записаны правила бизнеса. Это абсурд: правила не менялись.

Слои — это ответ на пункт 4, а всё остальное получается бонусом. Идея: вещи, которые меняются по разным причинам и с разной скоростью, должны лежать в разных модулях, и зависимость должна идти от быстро меняющегося к медленно меняющемуся, а не наоборот. Бизнес-правила живут годами, выбор веб-фреймворка — три года, версия драйвера БД — квартал. Значит, драйвер может знать про правила, а правила про драйвер — нет.

Это тот же принцип инверсии зависимостей из SOLID, поднятый с уровня класса на уровень модуля.

Эволюция: как мы сюда пришли

Обратите внимание на даты: за двадцать с лишним лет менялись не принципы, а словарь и строгость. Cockburn прямо писал, что придумал «шестиугольник» лишь потому, что не хотел рисовать очередной слоёный пирог — он вводил в заблуждение симметрией сверху и снизу.

Классическая многоуровневая архитектура

Интуиция. Ресторан: зал (официанты), кухня (повара), склад. Гость не ходит на склад, повар не принимает заказ у столика. Каждый уровень обращается только к соседнему снизу.

Каноническая раскладка на три-четыре уровня:

Уровень Ответственность Типичные обитатели
Presentation / UI Протокол общения с внешним миром контроллеры, DTO, сериализация, валидация формата
Application / Service Оркестрация сценария, транзакция сервисы приложения, use cases
Domain / Business Правила предметной области сущности, value objects, доменные сервисы
Data Access / Infrastructure Постоянное хранение и внешние вызовы репозитории, ORM, HTTP-клиенты

Ключевые решения, которые нужно принять явно:

  • Строгая или расслабленная (strict / relaxed) слоистость. Строгая: уровень обращается только к непосредственно нижележащему. Расслабленная: можно «перепрыгнуть» через уровень. Строгая порождает пустые методы-проброски, расслабленная — постепенное растворение границ. На практике почти всегда выбирают расслабленную и запрещают ровно одно направление: вверх.
  • Что за объект пересекает границу. Сущность домена? DTO? ORM-модель? Ответ определяет 80% боли в дальнейшем.

Пунктирные стрелки — это два классических способа сломать слоистую архитектуру, и оба встречаются почти в каждом проекте:

Утечка вверх. Data-access-уровень возвращает свои ORM-объекты, они путешествуют до шаблона или JSON-сериализатора. Формально «слои есть», фактически схема БД стала публичным API. Переименовали колонку — сломался фронтенд. Отсюда же берётся печально известный N+1: шаблон трогает ленивое свойство, ORM молча идёт в базу в цикле рендеринга.

Утечка вниз. Домен импортирует sqlalchemy / EntityFramework / ActiveRecord, потому что «так удобнее». Теперь бизнес-правило нельзя выполнить без соединения с БД.

Именно поэтому у классической слоистости плохая репутация. Заслуженная лишь наполовину: сама по себе она отличный дефолт для CRUD-систем, где логики мало, а данные почти напрямую отображаются в экраны. Fowler в PresentationDomainDataLayering прямо говорит: это первый разрез, который стоит делать, но не единственный и не обязательно главный — вертикальный разрез по фичам часто важнее горизонтального по технике.

Третья хроническая болезнь — анемичная модель: сущности превращаются в мешки геттеров/сеттеров, а вся логика уезжает в «сервисы». Формально слои соблюдены, фактически ООП нет: данные отдельно, поведение отдельно. Fowler разбирает это в AnemicDomainModel. Подробнее про построение богатой модели — в материалах трека DDD.

Правило зависимостей: механика

Все три «продвинутые» школы держатся на одном приёме. Он звучит так:

Зависимости в исходном коде указывают только внутрь. Ничто во внутреннем круге не знает имени сущности из внешнего круга.

Важно понять, что направление зависимости в коде и направление потока управления — разные вещи. Управление течёт от контроллера через сценарий к базе. Зависимость в коде — от базы к сценарию. Разворот делает интерфейс, объявленный внутри и реализованный снаружи.

Инверсия зависимости между бизнес-логикой и хранилищем

Слева — «естественная» зависимость: домен импортирует DAO, DAO импортирует драйвер. Справа — тот же поток управления, но интерфейс OrderRepository объявлен и принадлежит домену, а инфраструктура его реализует. Стрелка развёрнута, граф зависимостей превратился в дерево с бизнес-логикой в корне.

Отсюда три практических следствия, по которым архитектуру можно проверить за минуту:

  1. Тест компиляции. Модуль домена собирается отдельным пакетом, в его зависимостях нет ни веб-фреймворка, ни драйвера БД. Если go build ./internal/domain или python -c "import myapp.domain" тянет psycopg2 — правило нарушено.
  2. Тест grep. grep -R "sqlalchemy\|django\|fastapi" domain/ возвращает пусто.
  3. Тест владения интерфейсом. Файл с интерфейсом лежит рядом с тем, кто его вызывает, а не с тем, кто его реализует. Это и есть отличие «инверсии зависимостей» от «просто интерфейса».

Третий пункт — самый недооценённый. Интерфейс IOrderRepository, лежащий в пакете Infrastructure, не инвертирует ничего: домен по-прежнему смотрит наружу.

Гексагональная архитектура: порты и адаптеры

Alistair Cockburn, Hexagonal Architecture, 2005. Его собственная формулировка цели: «Позволить приложению одинаково работать под управлением пользователей, программ, автоматических тестов или пакетных скриптов, и разрабатываться и тестироваться в изоляции от исполняющих устройств и баз данных».

Словарь:

  • Порт — интерфейс, описанный в терминах приложения, а не технологии. Не PostgresConnection, а OrderRepository. Не SmtpSender, а NotifyCustomer.
  • Адаптер — реализация порта конкретной технологией. У одного порта их может быть несколько.
  • Driving / primary порты (слева) — то, чем приложение управляют: HTTP API, CLI, консьюмер очереди, тест. Адаптер вызывает приложение.
  • Driven / secondary порты (справа) — то, чем управляет приложение: БД, почта, платёжный шлюз. Приложение вызывает адаптер.

Шестиугольник не значит «шесть портов» — Cockburn выбрал форму, чтобы было место рисовать разные грани и чтобы не было верха и низа. Симметрия — главная мысль: HTTP и Postgres находятся снаружи в равной степени, оба всего лишь способ соединить приложение с миром.

Ключ к чтению диаграммы: все сплошные стрелки реализации (..|>) идут снаружи внутрь. Ни один класс приложения не упоминает Sql, Kafka или Http.

Самая ценная практическая выгода — не «сменить Postgres на Mongo» (этого почти никто не делает), а дублирование адаптеров для разных режимов работы: продакшн-адаптер и тестовый, боевой платёжный шлюз и песочница, синхронный вызов и очередь. Netflix описывал ровно этот мотив в Ready for changes with Hexagonal Architecture — им нужно было менять источники данных, не трогая логику.

Луковичная архитектура

Jeffrey Palermo, The Onion Architecture, 2008. Мотив тот же, акцент другой: Palermo пришёл из мира .NET, где типовой проект имел зависимость Web → BLL → DAL, и главным его тезисом было — «всё связывается с ядром домена, а инфраструктура выносится наружу; интерфейсы объявляются во внутренних кольцах».

Кольца снаружи внутрь: Infrastructure / Tests / UIApplication ServicesDomain ServicesDomain Model.

Отличия от гексагона в основном терминологические, но одно содержательное есть: луковица явно выделяет два вида сервисов — доменные (правила, не влезающие в одну сущность) и прикладные (оркестрация сценария, транзакция, координация портов). Гексагон это различие не навязывает.

Чистая архитектура

Robert Martin, The Clean Architecture, 2012, и одноимённая книга 2017 года. Это попытка синтеза: Мартин прямо перечисляет hexagonal, onion, DCI и BCE как источники и утверждает, что все они дают одно — систему, независимую от фреймворков, UI, БД и любых внешних агентов.

Кольца чистой архитектуры и соответствие терминов

Что чистая архитектура добавляет сверх предшественников:

1. Явное различение Entities и Use Cases. Сущности — правила уровня всего предприятия, живущие даже без этого приложения (как считается НДС, что заказ нельзя отменить после отгрузки). Сценарии — правила уровня приложения (в каком порядке дёргать порты, что вернуть, когда открыть транзакцию). Разница практическая: сущность переживёт переписывание приложения, сценарий — нет.

2. Формализованное пересечение границы. Данные через границу передаются простыми структурами (InputData / OutputData), а не сущностями и не строками БД. Не потому что «так красиво», а потому что общий тип на границе — это скрытая зависимость: изменение схемы БД добралось бы до презентера.

3. Приём с Presenter для разворота выходной зависимости. Наивно use case возвращает результат контроллеру, и получается, что «внутренний» слой знает о том, кто его позвал, только через возврат. Мартин предлагает Output Port: сценарий вызывает интерфейс OrderPresenter, объявленный внутри, реализованный снаружи. Это нужно, когда представление сложное или асинхронное; для обычного REST-эндпоинта простой возврат структуры — совершенно нормальная упрощённая форма, и настаивать на презентерах ради ортодоксии не стоит.

4. Screaming Architecture (статья 2011 года): верхний уровень дерева каталогов должен кричать о предметной области, а не о фреймворке. billing/, catalog/, shipping/ вместо controllers/, models/, serializers/.

Сравнение: что реально различается

Критерий Layered Hexagonal Onion Clean
Направление зависимостей сверху вниз внутрь внутрь внутрь, правило явное
Где объявлен интерфейс хранилища обычно в инфраструктуре в приложении (порт) в домене в домене/сценарии
Симметрия входа и выхода нет да (driving/driven) частично да (input/output port)
Различает домен- и app-сервисы нет не навязывает да да (Entities/Use Cases)
Регламент объектов на границе нет нет нет да (DTO, не сущности)
Сложность входа низкая средняя средняя высокая
Хорош, когда логики мало, CRUD много каналов ввода/вывода .NET/DDD-проекты долгоживущее ядро правил

Практический вывод: если вы уже применяете гексагональную архитектуру, «переход на чистую» даст вам два уточнения (типы на границе и явное разделение entities/use cases) и ноль структурных изменений. Полезное обобщение всех четырёх с картинками — у Herberto Graça, Explicit Architecture.

Рабочий пример

Соберём сценарий «оформить заказ» по гексагональной схеме. Язык — Python, никаких внешних зависимостей: домен обязан собираться в вакууме.

Домен: правила и ничего больше

# app/domain/model.py
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from decimal import Decimal
from enum import Enum


class DomainError(Exception):
    """Нарушение бизнес-правила. Не HTTP-ошибка — про HTTP домен не знает."""


class OrderStatus(str, Enum):
    DRAFT = "draft"
    PLACED = "placed"
    CANCELLED = "cancelled"


@dataclass(frozen=True)
class Money:
    amount: Decimal
    currency: str = "RUB"

    def __post_init__(self) -> None:
        if self.amount < 0:
            raise DomainError("Сумма не может быть отрицательной")

    def __add__(self, other: "Money") -> "Money":
        if self.currency != other.currency:
            raise DomainError(f"Нельзя складывать {self.currency} и {other.currency}")
        return Money(self.amount + other.amount, self.currency)

    def __mul__(self, k: int) -> "Money":
        return Money(self.amount * k, self.currency)

    def __gt__(self, other: "Money") -> bool:
        if self.currency != other.currency:
            raise DomainError("Несравнимые валюты")
        return self.amount > other.amount


@dataclass(frozen=True)
class OrderLine:
    sku: str
    qty: int
    price: Money

    def subtotal(self) -> Money:
        return self.price * self.qty


@dataclass
class Order:
    id: str
    customer_id: str
    lines: list[OrderLine] = field(default_factory=list)
    status: OrderStatus = OrderStatus.DRAFT
    placed_at: datetime | None = None

    # Порог, выше которого требуется верифицированный покупатель.
    VERIFICATION_THRESHOLD = Money(Decimal("100000"))

    def total(self) -> Money:
        total = Money(Decimal("0"))
        for line in self.lines:
            total = total + line.subtotal()
        return total

    def place(self, *, customer_verified: bool, now: datetime) -> "OrderPlaced":
        """Единственная точка, где заказ переходит в PLACED."""
        if self.status is not OrderStatus.DRAFT:
            raise DomainError(f"Заказ уже в статусе {self.status.value}")
        if not self.lines:
            raise DomainError("Нельзя оформить пустой заказ")
        if self.total() > self.VERIFICATION_THRESHOLD and not customer_verified:
            raise DomainError("Для крупного заказа нужна верификация покупателя")
        self.status = OrderStatus.PLACED
        self.placed_at = now
        return OrderPlaced(order_id=self.id, total=self.total(), at=now)


@dataclass(frozen=True)
class OrderPlaced:
    order_id: str
    total: Money
    at: datetime

Обратите внимание: правило про 100 000 теперь имеет один адресOrder.place. Инвариант «переход только из DRAFT» невозможно обойти, потому что статус меняется только внутри метода. Тест этого правила выполняется за микросекунды и не требует ничего, кроме импорта.

Жизненный цикл заказа как автомат — полезно зафиксировать явно, чтобы переходы не расползлись по сервисам:

Порты: интерфейсы, которыми владеет приложение

# app/application/ports.py
from datetime import datetime
from typing import Protocol
from app.domain.model import Order, OrderPlaced


class OrderRepository(Protocol):
    """Driven-порт. Формулировка — в терминах домена, не SQL."""

    def get(self, order_id: str) -> Order | None: ...
    def save(self, order: Order) -> None: ...


class CustomerDirectory(Protocol):
    def is_verified(self, customer_id: str) -> bool: ...


class EventPublisher(Protocol):
    def publish(self, event: OrderPlaced) -> None: ...


class Clock(Protocol):
    def now(self) -> datetime: ...


class UnitOfWork(Protocol):
    """Транзакционная граница — тоже порт: домен не знает про BEGIN/COMMIT."""

    orders: OrderRepository

    def __enter__(self) -> "UnitOfWork": ...
    def __exit__(self, *exc) -> None: ...
    def commit(self) -> None: ...

Protocol из typing даёт структурную типизацию: адаптеру не нужно наследоваться, достаточно совпадения сигнатур. В Java/C# здесь будут обычные интерфейсы, в Go — интерфейсы, объявленные на стороне потребителя (это идиома, см. Standard Package Layout).

Сценарий: оркестрация без правил

# app/application/place_order.py
from dataclasses import dataclass
from app.domain.model import DomainError
from app.application.ports import Clock, CustomerDirectory, EventPublisher, UnitOfWork


@dataclass(frozen=True)
class PlaceOrderCommand:
    """Простая структура на границе: ни ORM-модели, ни HTTP-запроса."""
    order_id: str


@dataclass(frozen=True)
class PlaceOrderResult:
    order_id: str
    total: str
    currency: str


class OrderNotFound(DomainError):
    pass


class PlaceOrder:
    """Driving-порт в виде класса. Знает 'что за чем', но не 'по каким правилам'."""

    def __init__(
        self,
        uow: UnitOfWork,
        customers: CustomerDirectory,
        events: EventPublisher,
        clock: Clock,
    ) -> None:
        self._uow = uow
        self._customers = customers
        self._events = events
        self._clock = clock

    def execute(self, cmd: PlaceOrderCommand) -> PlaceOrderResult:
        with self._uow as uow:
            order = uow.orders.get(cmd.order_id)
            if order is None:
                raise OrderNotFound(f"Заказ {cmd.order_id} не найден")

            verified = self._customers.is_verified(order.customer_id)
            event = order.place(customer_verified=verified, now=self._clock.now())

            uow.orders.save(order)
            uow.commit()

        # Публикуем только после успешного коммита — иначе рассылаем ложь.
        # Надёжный вариант — transactional outbox, см. статью про Saga.
        self._events.publish(event)

        total = order.total()
        return PlaceOrderResult(cmd.order_id, str(total.amount), total.currency)

Сценарий содержит ровно ноль условий бизнес-логики: всё решает order.place. Это лакмус — если в use case появляются if про суммы и статусы, логика утекла из домена.

Про «публиковать после коммита» и почему это всё ещё не гарантия — в статье Saga, распределённые транзакции, outbox и идемпотентность.

Адаптеры

# app/adapters/memory.py — тестовый driven-адаптер
from app.domain.model import Order, OrderPlaced


class InMemoryOrderRepository:
    def __init__(self, orders: list[Order] | None = None) -> None:
        self._store = {o.id: o for o in (orders or [])}

    def get(self, order_id: str) -> Order | None:
        return self._store.get(order_id)

    def save(self, order: Order) -> None:
        self._store[order.id] = order


class FakeUnitOfWork:
    def __init__(self, orders: InMemoryOrderRepository) -> None:
        self.orders = orders
        self.committed = False

    def __enter__(self) -> "FakeUnitOfWork":
        return self

    def __exit__(self, *exc) -> None:
        pass

    def commit(self) -> None:
        self.committed = True


class RecordingPublisher:
    def __init__(self) -> None:
        self.published: list[OrderPlaced] = []

    def publish(self, event: OrderPlaced) -> None:
        self.published.append(event)
# app/adapters/sqlite_repo.py — продакшн-подобный driven-адаптер
import json
import sqlite3
from decimal import Decimal
from app.domain.model import Money, Order, OrderLine, OrderStatus


class SqliteOrderRepository:
    """Единственное место в системе, где домен превращается в строки таблицы."""

    def __init__(self, conn: sqlite3.Connection) -> None:
        self._conn = conn

    def get(self, order_id: str) -> Order | None:
        row = self._conn.execute(
            "SELECT id, customer_id, status, lines FROM orders WHERE id = ?",
            (order_id,),
        ).fetchone()
        if row is None:
            return None
        return Order(
            id=row[0],
            customer_id=row[1],
            status=OrderStatus(row[2]),
            lines=[
                OrderLine(d["sku"], d["qty"], Money(Decimal(d["price"]), d["currency"]))
                for d in json.loads(row[3])
            ],
        )

    def save(self, order: Order) -> None:
        payload = json.dumps([
            {"sku": l.sku, "qty": l.qty,
             "price": str(l.price.amount), "currency": l.price.currency}
            for l in order.lines
        ])
        self._conn.execute(
            "INSERT INTO orders(id, customer_id, status, lines) VALUES(?,?,?,?) "
            "ON CONFLICT(id) DO UPDATE SET status=excluded.status, lines=excluded.lines",
            (order.id, order.customer_id, order.status.value, payload),
        )

Driving-адаптер — тонкий, его работа только переводить протокол в команду и доменную ошибку в код ответа:

# app/adapters/http_api.py
from fastapi import FastAPI, HTTPException
from app.application.place_order import PlaceOrder, PlaceOrderCommand, OrderNotFound
from app.domain.model import DomainError


def make_app(place_order: PlaceOrder) -> FastAPI:
    api = FastAPI()

    @api.post("/orders/{order_id}/place")
    def place(order_id: str):
        try:
            result = place_order.execute(PlaceOrderCommand(order_id))
        except OrderNotFound as e:
            raise HTTPException(status_code=404, detail=str(e))
        except DomainError as e:
            # Нарушение бизнес-правила -> 409/422. Маппинг живёт здесь,
            # потому что "409" — понятие HTTP, а не предметной области.
            raise HTTPException(status_code=409, detail=str(e))
        return {"order_id": result.order_id, "total": result.total}

    return api

Второй driving-адаптер поверх того же порта пишется за десять минут — и это главный дивиденд схемы:

# app/adapters/cli.py
import sys
from app.application.place_order import PlaceOrder, PlaceOrderCommand

def main(place_order: PlaceOrder) -> int:
    result = place_order.execute(PlaceOrderCommand(sys.argv[1]))
    print(f"{result.order_id}: {result.total} {result.currency}")
    return 0

Сборка: composition root

Единственное место, где встречаются все конкретные технологии, — точка входа. Никакого сервис-локатора, «магии» и глобальных синглтонов: явная проводка сверху вниз.

# app/main.py
import sqlite3
from datetime import datetime, timezone
from app.adapters.http_api import make_app
from app.adapters.sqlite_repo import SqliteOrderRepository
from app.application.place_order import PlaceOrder


class SystemClock:
    def now(self) -> datetime:
        return datetime.now(timezone.utc)


class SqliteUnitOfWork:
    def __init__(self, conn: sqlite3.Connection) -> None:
        self._conn = conn
        self.orders = SqliteOrderRepository(conn)

    def __enter__(self) -> "SqliteUnitOfWork":
        self._conn.execute("BEGIN")
        return self

    def __exit__(self, exc_type, *_) -> None:
        self._conn.rollback() if exc_type else None

    def commit(self) -> None:
        self._conn.commit()


def build():
    # HttpCustomerDirectory и KafkaEventPublisher — такие же тонкие адаптеры
    # driven-портов, как SqliteOrderRepository выше.
    from app.adapters.crm import HttpCustomerDirectory
    from app.adapters.kafka import KafkaEventPublisher

    conn = sqlite3.connect("orders.db", isolation_level=None)
    use_case = PlaceOrder(
        uow=SqliteUnitOfWork(conn),
        customers=HttpCustomerDirectory(base_url="https://crm.internal"),
        events=KafkaEventPublisher(topic="orders"),
        clock=SystemClock(),
    )
    return make_app(use_case)

Тесты, ради которых всё затевалось

# tests/test_place_order.py
from datetime import datetime, timezone
from decimal import Decimal
import pytest
from app.adapters.memory import FakeUnitOfWork, InMemoryOrderRepository, RecordingPublisher
from app.application.place_order import PlaceOrder, PlaceOrderCommand
from app.domain.model import DomainError, Money, Order, OrderLine, OrderStatus


class FrozenClock:
    def now(self): return datetime(2026, 7, 16, tzinfo=timezone.utc)

class Directory:
    def __init__(self, verified: bool): self._v = verified
    def is_verified(self, customer_id: str) -> bool: return self._v


def make_order(price: str, qty: int = 1) -> Order:
    return Order(id="o-1", customer_id="c-1",
                 lines=[OrderLine("sku-1", qty, Money(Decimal(price)))])


def build(order: Order, verified: bool):
    uow = FakeUnitOfWork(InMemoryOrderRepository([order]))
    events = RecordingPublisher()
    return PlaceOrder(uow, Directory(verified), events, FrozenClock()), uow, events


def test_обычный_заказ_оформляется():
    order = make_order("999.00")
    use_case, uow, events = build(order, verified=False)

    use_case.execute(PlaceOrderCommand("o-1"))

    assert order.status is OrderStatus.PLACED
    assert uow.committed
    assert len(events.published) == 1


def test_крупный_заказ_без_верификации_отклоняется():
    order = make_order("100001.00")
    use_case, _, events = build(order, verified=False)

    with pytest.raises(DomainError, match="верификация"):
        use_case.execute(PlaceOrderCommand("o-1"))

    assert order.status is OrderStatus.DRAFT
    assert events.published == []   # событие не ушло


def test_повторное_оформление_запрещено():
    order = make_order("100.00")
    order.status = OrderStatus.PLACED
    use_case, _, _ = build(order, verified=True)

    with pytest.raises(DomainError, match="уже в статусе"):
        use_case.execute(PlaceOrderCommand("o-1"))

Три содержательных теста бизнес-логики, время выполнения — единицы миллисекунд, зависимостей нет. В исходной версии с psycopg2.connect внутри контроллера каждый такой тест стоил бы поднятого контейнера с Postgres и сотен миллисекунд.

Поток управления целиком

Цена абстракции: считаем честно

Гексагональная схема не бесплатна. Прикинем стоимость на один агрегат с четырьмя CRUD-сценариями.

Артефакт Классическая слоистая Порты и адаптеры
Сущность / ORM-модель 1 файл 2 (доменная модель + схема хранения)
Репозиторий 1 класс 1 порт + 2 адаптера (боевой, in-memory)
Сценарии 4 метода сервиса 4 класса + 8 DTO (команда + результат)
Маппинг нет доменная модель ↔ строка БД, DTO ↔ JSON
Итого файлов ~4 ~12–15

Это в среднем втрое больше кода на фичу и одно новое место, где можно ошибиться, — маппинг. Стоимость пересчёта в рантайме почти нулевая (создание нескольких объектов на запрос — микросекунды на фоне сетевого RTT в миллисекундах), но когнитивная нагрузка реальна.

Когда цена оправдана:

  • Больше одного канала ввода (HTTP + очередь + cron + админка) — экономия начинается со второго.
  • Логики заметно больше, чем полей. Если сущность — это форма из двадцати полей, которую надо сохранить, слои дадут только маппинг.
  • Продолжительность жизни системы > 2–3 лет и планируемая смена части инфраструктуры.
  • Требуется быстрый тест-фидбек. Если полный прогон тестов занимает 20 минут из-за БД, изоляция домена окупится за месяцы.

Когда не оправдана:

  • Прототип, гипотеза, внутренний инструмент на три экрана.
  • Чистый CRUD-сервис-справочник: слои вырождаются в пробросы, а Order в домене становится точной копией строки таблицы. Fowler называет этот случай в LocalDTO — не создавайте DTO, идентичный сущности, только ради симметрии.
  • Команда меньше трёх человек, продукт ещё ищет форму: цена ошибки в границах выше выгоды.

Разумный компромисс, который хорошо работает на практике: изолируйте домен строго, а к адаптерам относитесь прагматично. Порт для БД — да. Отдельный DTO между сценарием и HTTP-контроллером внутри одного процесса — только когда форматы реально разошлись.

Как выбирать

Типичные ошибки

  1. Интерфейс лежит рядом с реализацией. Infrastructure/IOrderRepository.cs — это не инверсия, а просто лишний файл. Интерфейс принадлежит вызывающей стороне.
  2. Один интерфейс на одну реализацию «на всякий случай». Если у порта навсегда один адаптер и он даже не подменяется в тестах — это налог без выгоды. Порт оправдан, когда реализаций ≥ 2 (считая тестовую) или когда он защищает границу от чужой библиотеки.
  3. ORM-сущность как доменная модель. Соблазнительно и работает — ровно до момента, когда lazy loading выстреливает в шаблоне, а @Entity начинает диктовать, какие конструкторы разрешены. Если решились — сделайте это сознательно и запретите домену обращаться к сессии.
  4. «Анемичный» домен + толстые сервисы. Слои есть, логика в сервисах, сущности — DTO. Проверка: если в use case есть if про суммы, статусы и даты — правила сбежали.
  5. Абстрагирование ради абстрагирования. Порт IDateTimeProvider полезен. Порт ILogger поверх стандартного логгера — сомнительно. Порт IStringFormatter — вред.
  6. Утечка технологии сквозь порт. OrderRepository.find(spec: SqlSpecification) или репозиторий, возвращающий IQueryable/QuerySet, — формально абстракция, фактически SQL, просочившийся в домен. Порт должен выражаться в словах предметной области.
  7. Доменные исключения с HTTP-кодами. raise DomainError(status=409) — это HTTP внутри домена. Маппинг ошибки в код ответа — работа driving-адаптера.
  8. Слои есть, но в них нет модулей. Разрезали по технике (controllers/, services/, repositories/) и не разрезали по предметной области. При двадцати фичах каждый каталог — свалка. Вертикальный разрез (billing/, catalog/) обычно важнее горизонтального; подробнее — в статье про модульный монолит.
  9. Правило зависимостей держится на честном слове. Без автоматической проверки оно деградирует за квартал: кто-то торопился, ревьюер не заметил.
  10. Один и тот же уровень строгости для всей системы. Ядро биллинга и справочник стран не заслуживают одинаковых церемоний. Архитектура — это распределение бюджета сложности, а не равномерная его раздача.

Как это применяют в проде

Автоматический контроль границ. Единственный надёжный способ сохранить правило зависимостей — тест в CI. В JVM — ArchUnit:

@AnalyzeClasses(packages = "com.acme.orders")
class ArchitectureTest {

    @ArchTest
    static final ArchRule домен_не_знает_про_инфраструктуру =
        noClasses().that().resideInAPackage("..domain..")
            .should().dependOnClassesThat()
            .resideInAnyPackage("..infrastructure..", "..adapters..",
                                "org.springframework..", "javax.persistence..");

    @ArchTest
    static final ArchRule слои_соблюдаются =
        layeredArchitecture().consideringOnlyDependenciesInLayers()
            .layer("Adapters").definedBy("..adapters..")
            .layer("Application").definedBy("..application..")
            .layer("Domain").definedBy("..domain..")
            .whereLayer("Adapters").mayNotBeAccessedByAnyLayer()
            .whereLayer("Application").mayOnlyBeAccessedByLayers("Adapters");
}

В .NET — NetArchTest, в TypeScript — dependency-cruiser, в Python — import-linter:

# setup.cfg — import-linter
[importlinter]
root_package = app

[importlinter:contract:layers]
name = Правило зависимостей
type = layers
layers =
    app.adapters
    app.application
    app.domain

В Go проверка тривиальна и без библиотек:

# домен не должен тянуть ничего постороннего
go list -deps ./internal/domain | grep -E 'database/sql|net/http|github.com/' && exit 1

Такой тест стоит десять минут на настройку и превращает архитектуру из договорённости в свойство сборки. Это частный случай fitness function из эволюционной архитектуры — про них подробнее в статье Архитектурные решения.

Раскладка каталогов. Screaming Architecture на практике означает сначала разрез по предметной области, слои — внутри:

internal/
  billing/
    domain/        # сущности, value objects, доменные события
    app/           # сценарии + порты
    adapters/
      postgres/
      http/
      kafka/
  catalog/
    domain/
    app/
    adapters/
  platform/        # общий каркас: конфиг, трассировка, миграции
cmd/
  api/main.go      # composition root
  worker/main.go

Тестовая пирамида ложится на слои сама. Домен — быстрые модульные тесты без всего. Сценарии — тесты на фейковых адаптерах. Адаптеры — интеграционные тесты против реальной технологии (Testcontainers, sqlite/postgres). E2E — единицы штук на критичные пути. Percival & Gregory показывают эту связку целиком в бесплатной онлайн-книге Architecture Patterns with Python — лучший практический разбор портов, репозиториев и UoW из существующих.

Миграция легаси. Большой взрыв не работает. Работающий рецепт:

  1. Выберите один болезненный сценарий (тот, где чаще всего баги).
  2. Вытащите его правила в чистый доменный модуль без зависимостей, покройте тестами — старый код пока не трогаете.
  3. Объявите порты для того, к чему сценарий ходил, и напишите адаптеры-обёртки поверх существующего DAO. Это дёшево и не ломает соседей.
  4. Переключите старый контроллер на вызов нового use case. Старый код сценария удалите.
  5. Повторите. Границы вырастут там, где реально болит, а не там, где красиво на схеме.

Где эта архитектура не спасает. Правило зависимостей структурирует один деплой-юнит. Оно ничего не говорит про сетевые границы, согласованность между сервисами и задержки — это уже область микросервисов, событийной архитектуры и устойчивости. Гексагональный сервис может быть частью распределённой системы, а может быть модулем монолита — схема к этому ортогональна, и это её достоинство.

Мини-итог

  • Все четыре школы реализуют одно правило: зависимости в исходном коде направлены к тому, что меняется реже. Бизнес-правила — самое медленное, они в центре.
  • Механизм разворота — интерфейс, объявленный тем, кто его вызывает. Это единственная техническая суть; всё остальное — словарь.
  • Слоистая архитектура — нормальный дефолт для CRUD. Ломается она двумя утечками: ORM-модель наверх и фреймворк вниз, в домен.
  • Гексагональная добавляет симметрию: driving-порты (нас вызывают) и driven-порты (мы вызываем). Главный дивиденд — второй канал ввода и дешёвые тесты, а не гипотетическая смена БД.
  • Луковичная уточняет, что интерфейсы принадлежат внутренним кольцам, и разделяет доменные и прикладные сервисы.
  • Чистая добавляет два содержательных правила: простые структуры на границах и разделение Entities (правила предприятия) / Use Cases (правила приложения).
  • Абстракция стоит примерно втрое больше кода на фичу. Платите её там, где логики много и жизнь длинная; не платите в справочниках и прототипах.
  • Без автоматической проверки границ в CI любое из этих правил разлагается за квартал. ArchUnit / import-linter / dependency-cruiser — обязательный элемент, а не приятное дополнение.

Источники

Что дальше

Мы разобрались, как организовать код внутри одного деплой-юнита. Следующий вопрос — сколько таких юнитов вам нужно и почему ответ «один» гораздо чаще правильный, чем принято думать: Монолит и модульный монолит: недооценённый выбор.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов