Многоуровневая, гексагональная, луковичная и чистая архитектуры
Есть вопрос, который решает любая кодовая база размером больше одного файла: где живёт бизнес-логика и от чего она имеет право зависеть. Ответов накопилось четыре популярных — 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}
Код работает. Проблемы начинаются позже и все — про связность решений разной скорости изменения:
- Правило «заказ больше 100 000 требует верификации» нельзя найти. Оно размазано по HTTP-контроллеру. Через год таких правил тридцать, и живут они в двенадцати контроллерах, четырёх cron-скриптах и одной админке.
- Нельзя протестировать без Postgres и без сети. Тест бизнес-правила требует поднять БД, замокать Slack и сформировать HTTP-запрос. Стоимость проверки одной строчки логики — секунды вместо микросекунд.
- Нельзя переиспользовать. Приехала задача «то же самое, но из очереди Kafka» — придётся копировать.
- Смена технологии = переписывание логики. Миграция с 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 объявлен и принадлежит домену, а инфраструктура его реализует. Стрелка развёрнута, граф зависимостей превратился в дерево с бизнес-логикой в корне.
Отсюда три практических следствия, по которым архитектуру можно проверить за минуту:
- Тест компиляции. Модуль домена собирается отдельным пакетом, в его зависимостях нет ни веб-фреймворка, ни драйвера БД. Если
go build ./internal/domainилиpython -c "import myapp.domain"тянетpsycopg2— правило нарушено. - Тест grep.
grep -R "sqlalchemy\|django\|fastapi" domain/возвращает пусто. - Тест владения интерфейсом. Файл с интерфейсом лежит рядом с тем, кто его вызывает, а не с тем, кто его реализует. Это и есть отличие «инверсии зависимостей» от «просто интерфейса».
Третий пункт — самый недооценённый. Интерфейс 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 / UI → Application Services → Domain Services → Domain 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 и сотен миллисекунд.
Поток управления целиком
(FastAPI) participant UC as PlaceOrder
(сценарий) participant D as Order
(домен) participant R as SqliteOrderRepository
(driven-адаптер) participant K as KafkaEventPublisher Cl->>A: POST /orders/o-1/place A->>A: разобрать протокол -> PlaceOrderCommand A->>UC: execute(cmd) UC->>R: uow.orders.get("o-1") R-->>UC: Order (собран из строк БД) UC->>UC: customers.is_verified(...) UC->>D: order.place(verified, now) alt правило нарушено D-->>UC: raise DomainError UC-->>A: DomainError A-->>Cl: 409 Conflict else успех D-->>UC: OrderPlaced UC->>R: save(order) UC->>UC: commit() UC->>K: publish(OrderPlaced) UC-->>A: PlaceOrderResult A-->>Cl: 200 OK end Note over UC,R: Поток управления идёт наружу,
но в коде UC зависит только от порта
Цена абстракции: считаем честно
Гексагональная схема не бесплатна. Прикинем стоимость на один агрегат с четырьмя 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-контроллером внутри одного процесса — только когда форматы реально разошлись.
Как выбирать
чем полей формы?"} Q1 -- Нет --> CRUD["Слоистая или даже
Transaction Script.
Не выдумывайте домен"] Q1 -- Да --> Q2{"Каналов ввода
больше одного?"} Q2 -- Нет --> Q3{"Правила переживут
смену UI и хранилища?"} Q2 -- Да --> HEX["Порты и адаптеры:
driving-порт переиспользуется"] Q3 -- Нет --> LAY["Слоистая + строгий запрет
импорта фреймворка в домен"] Q3 -- Да --> Q4{"Много сценариев
вокруг одних сущностей?"} Q4 -- Нет --> HEX Q4 -- Да --> CLEAN["Чистая: разделить
Entities и Use Cases,
DTO на границах"] CRUD --> Guard["В любом случае: тест архитектуры в CI"] HEX --> Guard LAY --> Guard CLEAN --> Guard
Типичные ошибки
- Интерфейс лежит рядом с реализацией.
Infrastructure/IOrderRepository.cs— это не инверсия, а просто лишний файл. Интерфейс принадлежит вызывающей стороне. - Один интерфейс на одну реализацию «на всякий случай». Если у порта навсегда один адаптер и он даже не подменяется в тестах — это налог без выгоды. Порт оправдан, когда реализаций ≥ 2 (считая тестовую) или когда он защищает границу от чужой библиотеки.
- ORM-сущность как доменная модель. Соблазнительно и работает — ровно до момента, когда
lazy loadingвыстреливает в шаблоне, а@Entityначинает диктовать, какие конструкторы разрешены. Если решились — сделайте это сознательно и запретите домену обращаться к сессии. - «Анемичный» домен + толстые сервисы. Слои есть, логика в сервисах, сущности — DTO. Проверка: если в use case есть
ifпро суммы, статусы и даты — правила сбежали. - Абстрагирование ради абстрагирования. Порт
IDateTimeProviderполезен. ПортILoggerповерх стандартного логгера — сомнительно. ПортIStringFormatter— вред. - Утечка технологии сквозь порт.
OrderRepository.find(spec: SqlSpecification)или репозиторий, возвращающийIQueryable/QuerySet, — формально абстракция, фактически SQL, просочившийся в домен. Порт должен выражаться в словах предметной области. - Доменные исключения с HTTP-кодами.
raise DomainError(status=409)— это HTTP внутри домена. Маппинг ошибки в код ответа — работа driving-адаптера. - Слои есть, но в них нет модулей. Разрезали по технике (
controllers/,services/,repositories/) и не разрезали по предметной области. При двадцати фичах каждый каталог — свалка. Вертикальный разрез (billing/,catalog/) обычно важнее горизонтального; подробнее — в статье про модульный монолит. - Правило зависимостей держится на честном слове. Без автоматической проверки оно деградирует за квартал: кто-то торопился, ревьюер не заметил.
- Один и тот же уровень строгости для всей системы. Ядро биллинга и справочник стран не заслуживают одинаковых церемоний. Архитектура — это распределение бюджета сложности, а не равномерная его раздача.
Как это применяют в проде
Автоматический контроль границ. Единственный надёжный способ сохранить правило зависимостей — тест в 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 из существующих.
Миграция легаси. Большой взрыв не работает. Работающий рецепт:
- Выберите один болезненный сценарий (тот, где чаще всего баги).
- Вытащите его правила в чистый доменный модуль без зависимостей, покройте тестами — старый код пока не трогаете.
- Объявите порты для того, к чему сценарий ходил, и напишите адаптеры-обёртки поверх существующего DAO. Это дёшево и не ломает соседей.
- Переключите старый контроллер на вызов нового use case. Старый код сценария удалите.
- Повторите. Границы вырастут там, где реально болит, а не там, где красиво на схеме.
Где эта архитектура не спасает. Правило зависимостей структурирует один деплой-юнит. Оно ничего не говорит про сетевые границы, согласованность между сервисами и задержки — это уже область микросервисов, событийной архитектуры и устойчивости. Гексагональный сервис может быть частью распределённой системы, а может быть модулем монолита — схема к этому ортогональна, и это её достоинство.
Мини-итог
- Все четыре школы реализуют одно правило: зависимости в исходном коде направлены к тому, что меняется реже. Бизнес-правила — самое медленное, они в центре.
- Механизм разворота — интерфейс, объявленный тем, кто его вызывает. Это единственная техническая суть; всё остальное — словарь.
- Слоистая архитектура — нормальный дефолт для CRUD. Ломается она двумя утечками: ORM-модель наверх и фреймворк вниз, в домен.
- Гексагональная добавляет симметрию: driving-порты (нас вызывают) и driven-порты (мы вызываем). Главный дивиденд — второй канал ввода и дешёвые тесты, а не гипотетическая смена БД.
- Луковичная уточняет, что интерфейсы принадлежат внутренним кольцам, и разделяет доменные и прикладные сервисы.
- Чистая добавляет два содержательных правила: простые структуры на границах и разделение Entities (правила предприятия) / Use Cases (правила приложения).
- Абстракция стоит примерно втрое больше кода на фичу. Платите её там, где логики много и жизнь длинная; не платите в справочниках и прототипах.
- Без автоматической проверки границ в CI любое из этих правил разлагается за квартал. ArchUnit / import-linter / dependency-cruiser — обязательный элемент, а не приятное дополнение.
Источники
- Alistair Cockburn. Hexagonal Architecture (Ports and Adapters), 2005.
- Jeffrey Palermo. The Onion Architecture, 2008.
- Robert C. Martin. The Clean Architecture, 2012; книга Clean Architecture: A Craftsman’s Guide to Software Structure and Design, 2017.
- Martin Fowler. PresentationDomainDataLayering, AnemicDomainModel, LocalDTO.
- Martin Fowler. Patterns of Enterprise Application Architecture, 2002 — Service Layer, Domain Model, Repository, Data Mapper.
- Eric Evans. Domain-Driven Design, 2003; Vaughn Vernon. Implementing Domain-Driven Design, 2013.
- Harry Percival, Bob Gregory. Architecture Patterns with Python — бесплатная онлайн-версия.
- Herberto Graça. Explicit Architecture: DDD, Hexagonal, Onion, Clean, CQRS — how I put it all together.
- Netflix Technology Blog. Ready for changes with Hexagonal Architecture.
- Ben Johnson. Standard Package Layout — идиоматичная реализация тех же идей в Go.
- Инструменты контроля: ArchUnit, import-linter, dependency-cruiser, NetArchTest.
Что дальше
Мы разобрались, как организовать код внутри одного деплой-юнита. Следующий вопрос — сколько таких юнитов вам нужно и почему ответ «один» гораздо чаще правильный, чем принято думать: Монолит и модульный монолит: недооценённый выбор.