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 внешней системы.