← назад к разделу

Hexagonal Architecture (она же «порты и адаптеры») — полезный инструмент, но не для каждого сервиса. В этой статье разберём, когда она реально даёт выигрыш, а когда лишь добавляет лишнего кода.

Проблема, которую решает Hexagonal

Представьте сервис на FastAPI. Сначала он маленький: роутеры, немного SQLAlchemy, пара HTTP-запросов во внешние системы. Всё в одном файле или в нескольких плоских модулях. Разработчики понимают структуру, тесты запускаются быстро.

Через несколько месяцев добавляются интеграции: платёжный шлюз, очередь сообщений, внешний сервис уведомлений. Бизнес-логика усложняется — появляются правила подтверждения, инварианты, события. И вот в одном обработчике оказывается: SQLAlchemy-запрос, вызов платёжного API, бизнес-проверка и формирование ответа — всё вперемешку.

Чтобы протестировать бизнес-правило, нужно поднять базу данных и замокать HTTP. Поменять хранилище — переписывать половину обработчиков. Новый разработчик не может найти, где живёт «настоящая» логика.

Hexagonal Architecture — это способ организовать код так, чтобы бизнес-логика была отделена от всего внешнего: HTTP, БД, очередей. Граница называется «порт», конкретная реализация за границей — «адаптер».

core/order/       ← чистый Python, никакого FastAPI или SQLAlchemy
  aggregate.py    ← бизнес-правила
  port/           ← интерфейсы (Protocol) к внешним системам

adapters/in/http/ ← FastAPI: принять запрос, вызвать use case
adapters/out/pg/  ← SQLAlchemy: реализация порта к базе данных

app/              ← сборка: связать порты с адаптерами, запустить

Но за это нужно платить: дополнительные пакеты, mappers между слоями, явная «проводка» зависимостей. Поэтому важно понять, когда это оправдано.

Когда Hexagonal реально помогает

Несколько внешних систем с разной логикой

Если сервис работает только с одной базой данных — Hexagonal не нужна. Но когда добавляется платёжный шлюз, очередь событий, SMS-сервис — каждая система приносит свои форматы, свои правила повтора при ошибках, свои особенности.

Без чёткой границы всё это оседает в обработчике:

# Без Hexagonal: обработчик знает про Сбер, SQLAlchemy и Kafka одновременно
class ConfirmOrderHandler:
    async def handle(self, cmd: ConfirmOrderCommand) -> None:
        async with self.session.begin():
            order = await self.session.get(OrderOrmModel, cmd.order_id)
            sber_resp = await self.http.post(
                "https://securepayments.sberbank.ru/payment/rest/register.do",
                json={"amount": order.total_kopecks, "orderNumber": str(order.id)},
            )
            if sber_resp.json()["errorCode"] != "0":
                raise ValueError(sber_resp.json()["errorMessage"])
            order.status = "confirmed"

С портами каждая внешняя система скрыта за интерфейсом. Тест проверяет бизнес-логику, подставляя простую заглушку вместо реального HTTP:

# core/order/port/out/payment_port.py
class PaymentPort(Protocol):
    async def register_payment(self, order: Order) -> PaymentRef: ...

# adapters/out/sber/sber_payment_adapter.py
class SberPaymentAdapter:
    async def register_payment(self, order: Order) -> PaymentRef:
        req = SberMapper.to_register_request(order)
        resp = await self._client.post("/payment/rest/register.do", json=req)
        return SberMapper.to_payment_ref(resp.json())

Сложный домен с бизнес-правилами

Если у заказа есть правила («нельзя подтвердить пустой заказ», «нельзя дважды подтвердить»), их стоит выделить в чистый Python-объект и тестировать без поднятия инфраструктуры:

# core/order/aggregate.py — только stdlib, никаких внешних зависимостей
@dataclass
class Order:
    id: OrderId
    items: list[OrderItem]
    status: OrderStatus
    events: list[DomainEvent] = field(default_factory=list)

    def confirm(self) -> None:
        if self.status != OrderStatus.PENDING:
            raise OrderAlreadyConfirmedError(self.id)
        if not self.items:
            raise EmptyOrderError(self.id)
        self.status = OrderStatus.CONFIRMED
        self.events.append(OrderConfirmed(order_id=self.id))

Тест запускается за миллисекунды, без базы данных и FastAPI:

def test_confirm_empty_order_raises():
    order = Order(id=OrderId.generate(), items=[], status=OrderStatus.PENDING)
    with pytest.raises(EmptyOrderError):
        order.confirm()

Если же «логика» — это просто customer.update_email(new_email) без проверок, Hexagonal ничего не даёт.

Несколько точек входа

HTTP для клиентов, HTTP для операторов с другими правами, Kafka consumer, периодические задачи — у каждой точки входа свои правила авторизации. Hexagonal позволяет чётко разделить их:

adapters/in/http/customer/   # JWT, публичные эндпоинты
adapters/in/http/operator/   # service-to-service
adapters/in/kafka/           # consumer, без HTTP-авторизации
adapters/in/cron/            # периодические задачи

Инструмент import-linter в CI не даст случайно смешать логику клиентского и операторского адаптеров.

Тесты требуют поднять всё приложение

Симптом: чтобы проверить одно бизнес-правило, нужен TestClient(app) с тестовой базой данных, потому что обработчик напрямую импортирует SQLAlchemy. Hexagonal убирает эту зависимость: core/ не знает про SQLAlchemy, тест работает с чистыми Python-объектами.

Команда растёт

Для двух разработчиков границы держатся устно. Когда команда становится больше, import-linter в CI автоматически ловит нарушения — например, случайный from sqlalchemy.orm import Session в файле агрегата:

# pyproject.toml
[[tool.importlinter.contracts]]
name = "core-must-not-import-infrastructure"
type = "forbidden"
source_modules = ["order_service.core"]
forbidden_modules = ["fastapi", "sqlalchemy", "pydantic", "httpx"]

Когда Hexagonal — лишнее

Простой CRUD с одной базой данных

Каталог товаров: получить список, обновить цену. Единственная внешняя зависимость — PostgreSQL. Здесь Repository-pattern в плоской структуре полностью справляется:

class ProductRepository:
    def __init__(self, session: AsyncSession) -> None:
        self._session = session

    async def find_by_id(self, product_id: ProductId) -> Product | None:
        row = await self._session.get(ProductOrmModel, product_id.value)
        return ProductMapper.to_domain(row) if row else None

Добавлять порт-Protocol, отдельный пакет адаптера и настраивать import-linter ради одной базы данных — это усложнение без выгоды.

Маленький сервис или маленькая команда

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

Логика ещё не устоялась

В начале продукта «Клиент» может стать «Аккаунтом», «Заказ» — «Договором». Hexagonal с её mappers и Protocol-интерфейсами замедляет итерации: каждое «попробуем иначе» превращается в переписывание нескольких mappers и портов. Сначала стоит найти устойчивую форму предметной области — потом накладывать жёсткую структуру.

Две частые ошибки при внедрении

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

Решение принимается на уровне каждого сервиса. Рядом могут жить простой CRUD-сервис и сложный сервис с портами — это нормально.

Частичная структура без реальной границы. Папки core/ и adapters/ созданы, но роутеры всё равно содержат бизнес-логику, а SQLAlchemy-модели используются внутри core/:

# Частичная структура: папки есть, граница не держится
@router.post("/orders/{order_id}/confirm")
async def confirm_order(order_id: UUID, session: AsyncSession = Depends(get_session)):
    order_row = await session.get(OrderOrmModel, order_id)  # SQLAlchemy в роутере
    if order_row.total > 50_000:                            # бизнес-логика в роутере
        raise HTTPException(status_code=400, detail="Превышен лимит")
    order_row.status = "confirmed"

Это хуже, чем честная плоская структура: import-linter не ловит нарушения, тесты всё равно требуют поднять HTTP и БД, а разработчик при чтении кода не знает, где реально проходит граница.

Правило простое: либо полный Hexagonal с import-linter-контрактом в CI и mappers между слоями, либо плоская структура без претензий на разделение. Промежуточное состояние допустимо только с явным сроком завершения перехода.

Коротко

  • Hexagonal Architecture отделяет бизнес-логику от HTTP, базы данных и внешних сервисов через явные интерфейсы («порты») и их реализации («адаптеры»).
  • Применять стоит, когда есть несколько внешних систем, сложный домен с бизнес-правилами, несколько точек входа с разными правилами авторизации или команда от трёх и более разработчиков.
  • Не применять, когда сервис — простой CRUD с одной базой, команда маленькая, логика ещё меняется.
  • В Python нет compile-time изоляции модулей: import-linter — единственный инструмент, который автоматически держит границы.
  • Структура без import-linter-контракта в CI — это не Hexagonal, а просто набор папок.
  • Cargo-cult (hex везде) и частичная структура (папки есть, граница не держится) — обе ситуации хуже, чем честная простая структура.

Что почитать дальше

  • Структура модулей — как организовать пакеты core/adapters/app/.
  • Core слой — что попадает в core/ и какие зависимости допустимы.
  • Порты — как описывать outbound-порты через Protocol в Python.
  • Адаптеры in — роутер FastAPI: маппинг Pydantic DTO в команду use case.
  • Адаптеры out — реализация порта-Protocol, маппинг domain и DTO внешней системы.