Hexagonal (Ports & Adapters)
Cockburn (2005): изоляция core от technology через Ports (интерфейсы) и Adapters (реализации). Primary/secondary ports. Идиоматичная реализация на Python Protocol.
TL;DR
- Hexagonal — исторически первая формулировка «изолируйте core от инфраструктуры». До Clean, до Onion.
- Primary port — вход в core (HTTP handler, message subscriber). Secondary port — то, что core вызывает (repository, gateway).
- Гексагон в названии условен. Значение — только чёткая граница между core и adapters.
- Идиома в Python — Port через Protocol (PEP 544). Реализация не декларирует зависимость от порта.
История и мотивация
Allow an application to equally be driven by users, programs, automated test or batch scripts, and to be developed and tested in isolation from its eventual run-time devices and databases.
Cockburn писал это в ответ на конкретную боль: приложения намертво прибиты к UI-фреймворку и БД. Тесты бизнес-логики требуют поднять браузер и БД. Переезд с Oracle на PostgreSQL — переписывание половины сервисов.
Решение: бизнес-логика ничего не знает о технологиях. Общение — только через контрактные интерфейсы (Ports), реализуемые снаружи (Adapters).
Гексагон в названии — просто фигура для рисования. Могло быть кругом, восьмиугольником, квадратом. Cockburn выбрал гексагон, потому что он даёт «естественные» стороны для разных типов адаптеров (UI, DB, тесты, …).
Ports & Adapters
Абстрактный интерфейс, через который core общается с внешним миром. Определяется потребителем (core), реализуется провайдером (адаптером).
Конкретная реализация порта. Живёт снаружи core. Знает про конкретную технологию: HTTP, SQL, брокеры.
Два вида портов:
- Primary (driving) port — способ войти в приложение. HTTP-контроллер, CLI-команда, message subscriber. Клиент вызывает port, port вызывает use case.
- Secondary (driven) port — способ, которым приложение обращается наружу. Repository, external API, publisher.
Идиома Python: Port через Protocol
Python 3.8+ (PEP 544) поддерживает structural subtyping через typing.Protocol. Это идеальный инструмент для 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: ...
Ключевое: реализация не наследуется.
class PostgresOrderRepository:
"""Не пишем: PostgresOrderRepository(OrderRepositoryPort)."""
def __init__(self, session: AsyncSession):
self._session = session
async def find_by_id(self, order_id: OrderId) -> Order | None:
...
async def save(self, order: Order) -> None:
...
PostgresOrderRepository не декларирует зависимость от OrderRepositoryPort — но удовлетворяет его структурно. Это идеальная инверсия зависимостей: реализация не знает об интерфейсе, интерфейс не знает о реализации.
Sanity-check: type-checker (mypy, pyright) проверит совместимость на этапе связывания в composition root.
▸ ABC vs Protocol
Можно использовать abc.ABC — реализация тогда наследуется, class PostgresOrderRepository(OrderRepositoryPort):. Работает, но:
- ABC заставляет реализацию декларировать зависимость от порта.
- Инверсия «размыта»: если что-то импортирует ABC, значит порт всё-таки на пути import-графа реализации.
- Nominal typing вместо structural — более жёсткое.
Protocol — чище. ABC — приемлемо, если хочется runtime-проверок через isinstance (Protocol это тоже умеет через @runtime_checkable, но с оговорками).
Papkovaya структура
Типичная структура Hexagonal-сервиса на Python:
app/
├── domain/ # entities, VOs, aggregates, domain services
│ ├── order.py
│ └── money.py
├── application/ # use cases + Ports
│ ├── ports/
│ │ ├── order_repository.py
│ │ ├── payment_gateway.py
│ │ └── event_publisher.py
│ └── use_cases/
│ ├── place_order.py
│ └── cancel_order.py
├── infrastructure/ # secondary adapters
│ ├── db/
│ ├── payment/
│ └── messaging/
└── interface/ # primary adapters
├── http/
│ ├── routes.py
│ └── schemas.py
└── amqp/
└── handlers.py
domain/+application/= core (внутренность гексагона).infrastructure/+interface/= адаптеры (за границей гексагона).
Правило: domain/ и application/ не импортируют infrastructure/ и interface/. Обратное — можно.
Пример: полный поток
Разберём один сценарий сквозным путём: HTTP-запрос → use case → БД + событие.
from dataclasses import dataclass, field
from decimal import Decimal
from typing import NewType
OrderId = NewType('OrderId', int)
@dataclass
class Order:
id: OrderId
total: Decimal
status: str = 'pending'
def confirm(self) -> 'OrderConfirmed':
if self.status != 'pending':
raise ValueError(f'cannot confirm order in state {self.status}')
self.status = 'confirmed'
return OrderConfirmed(order_id=self.id)
class OrderRepositoryPort(Protocol):
async def find_by_id(self, order_id: OrderId) -> Order | None: ...
async def save(self, order: Order) -> None: ...
class EventPublisherPort(Protocol):
async def publish(self, event: DomainEvent) -> None: ...
@dataclass(frozen=True)
class ConfirmOrderCommand:
order_id: OrderId
class ConfirmOrderUseCase:
def __init__(
self,
orders: OrderRepositoryPort,
events: EventPublisherPort,
) -> None:
self._orders = orders
self._events = events
async def execute(self, cmd: ConfirmOrderCommand) -> None:
order = await self._orders.find_by_id(cmd.order_id)
if order is None:
raise OrderNotFound(cmd.order_id)
event = order.confirm()
await self._orders.save(order)
await self._events.publish(event)
Что видно:
- Use case знает про
Portы, ничего про SQLAlchemy или RabbitMQ. Order.confirm()— бизнес-инвариант живёт в domain, не в use case.ConfirmOrderCommand— плоский DTO, никакой связи с HTTP или AMQP.
Primary adapter:
from fastapi import APIRouter
from dishka.integrations.fastapi import FromDishka
router = APIRouter(prefix='/orders')
@router.post('/{order_id}/confirm')
async def confirm(
order_id: int,
use_case: FromDishka[ConfirmOrderUseCase],
) -> dict:
await use_case.execute(ConfirmOrderCommand(order_id=OrderId(order_id)))
return {'status': 'ok'}
Secondary adapter:
class PostgresOrderRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def find_by_id(self, order_id: OrderId) -> Order | None:
row = await self._session.get(OrderRow, order_id)
return _to_domain(row) if row else None
async def save(self, order: Order) -> None:
row = _to_orm(order)
await self._session.merge(row)
Composition root связывает всё вместе. Порт-к-адаптеру — one-to-one, но use case видит только Port.
Тестируемость
Главная выгода Hexagonal — прозрачные тесты бизнес-логики без внешнего мира.
class InMemoryOrderRepository:
def __init__(self, seed: dict[OrderId, Order] = None):
self._store = seed or {}
async def find_by_id(self, order_id):
return self._store.get(order_id)
async def save(self, order):
self._store[order.id] = order
class CapturingPublisher:
def __init__(self):
self.events = []
async def publish(self, event):
self.events.append(event)
async def test_confirm_order_success():
repo = InMemoryOrderRepository({OrderId(1): Order(id=OrderId(1), total=Decimal('100'))})
publisher = CapturingPublisher()
use_case = ConfirmOrderUseCase(orders=repo, events=publisher)
await use_case.execute(ConfirmOrderCommand(order_id=OrderId(1)))
assert repo._store[OrderId(1)].status == 'confirmed'
assert len(publisher.events) == 1
Никакой SQLAlchemy, никакого RabbitMQ. Ноль моков (структурно — Protocol удовлетворяется автоматически). Тест — быстрый, детерминированный, читаемый.
Как не надо
1. Port ради Port’а
class OrderRepositoryPort(Protocol):
async def get(self, order_id: int) -> dict: ...
async def save(self, data: dict) -> None: ...
Порт возвращает dict. Домена нет. Port — просто «обёртка над словарём». Инверсия зависимостей — фикция, потому что и core, и adapter общаются через один и тот же неструктурированный тип.
Правильно: port возвращает Order (aggregate), а не dict. Иначе инверсия не защищает от изменений формата.
2. Adapter знает про Application
from app.application.use_cases.place_order import PlaceOrderUseCase
class PostgresOrderRepository:
async def save(self, order):
# ... сохранить
await PlaceOrderUseCase(...).notify(order)
Adapter вызывает use case. Стрелка зависимости перевёрнута дважды: adapter знает про core, а не наоборот. Гексагон разрушен.
Правильно: если после сохранения нужно что-то сделать — либо это делает use case, либо это порт EventPublisherPort, реализуемый другим adapter’ом.
3. Один жирный порт на все операции
class DatabasePort(Protocol):
async def get_order(self, id): ...
async def save_order(self, order): ...
async def get_customer(self, id): ...
async def save_customer(self, customer): ...
async def get_invoice(self, id): ...
...
15 методов на разные сущности. Первое нарушение ISP (Interface Segregation): use case, работающий только с Order, вынужден принимать порт с методами про Customer и Invoice.
Правильно: один порт на один aggregate. OrderRepositoryPort, CustomerRepositoryPort, InvoiceRepositoryPort.
4. Domain в domain, ORM тоже в domain
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)
...
Domain-класс наследуется от Base из infrastructure. Core теперь зависит от SQLAlchemy — весь смысл Hexagonal утерян. См. главу Clean Architecture — там разбор с рефакторингом.
Практика: разложите по слоям
Практика
Разложите код по слоям
Захватите карточку и перетащите в один из четырёх слоёв. Затем нажмите «Проверить».
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:
...Ядро бизнес-правил. Не знает про фреймворки, БД, HTTP. Entities, Value Objects, Aggregates, Domain Services.
Пусто. Перетащите сюда.
Оркестрация бизнес-сценариев. Use Cases и Ports (интерфейсы для внешних систем). Не знает про UI и БД конкретно.
Пусто. Перетащите сюда.
Адаптеры к внешнему миру: БД, HTTP-клиенты, брокеры, ORM, файловая система.
Пусто. Перетащите сюда.
Точки входа: HTTP-контроллеры, message handlers, CLI, GraphQL resolvers. Транспортный слой.
Пусто. Перетащите сюда.
Trade-offs
| Ситуация | Hexagonal ок | Layered / что-то проще |
|---|---|---|
| Сервис с нетривиальным доменом | ✓ | |
| Хотим быстрые unit-тесты бизнес-логики | ✓ | |
| Несколько транспортов (HTTP + AMQP) | ✓ | |
| Планируется смена БД / внешних сервисов | ✓ | |
| CRUD на 2 таблицы, команда из 1 человека | ✓ | |
| Прототип на неделю | ✓ | |
| Скрипт-обработчик разового запуска | ✓ |
Один нетривиальный контр-аргумент: если core — тривиальный CRUD, то Hexagonal вам даст «пустой гексагон» — вся сложность окажется в адаптерах, а внутри — просто перекладывание из формы в БД. В этом случае Layered честнее.
В твоём же коде
Разбор из анонимизированного микросервиса — практически хрестоматийная Hexagonal:
app/
├── application/
│ ├── ports/
│ │ ├── normalizer.py # NormalizerPort
│ │ ├── repository.py # ItemRepositoryPort
│ │ └── event_publisher.py # EventPublisherPort
│ └── use_cases/
│ └── standardize.py # OrderStandardizeUseCase
├── infrastructure/
│ ├── marketplace_adapter.py # реализует NormalizerPort через httpx
│ ├── db/repository.py # реализует ItemRepositoryPort
│ └── rabbitmq/publisher.py # реализует EventPublisherPort
└── interface/
└── rabbitmq/handlers.py # primary adapter
Что тут хорошо:
- Все port’ы через
Protocol. - Реализации не декларируют зависимость от port’ов — чистая инверсия.
- Composition root на Dishka явно связывает
Port → Adapter.
Что можно улучшить:
domain/пустой (нет aggregates). Это приемлемо для сервиса-оркестратора, но при усложнении логика начнёт скапливаться в use case._adapt_for_consumerв use case — утечка контракта consumer’а в бизнес-слой. Должен быть отдельныйOutgoingMessageMapperв infrastructure. Разбор — на странице Case study.
Дальнейшее чтение
- Alistair Cockburn. Hexagonal Architecture. 2005. Оригинал.
- Vaughn Vernon. Implementing Domain-Driven Design. Chapter 4. Hexagonal как основа архитектуры DDD-сервиса.
- Herberto Graça. Ports & Adapters Architecture. Разбор с картинками.
- Harry Percival, Bob Gregory. Architecture Patterns with Python. Практическая Hexagonal + DDD на Python. Бесплатно.
- Tom Hombergs. Get Your Hands Dirty on Clean Architecture. Тот же подход в Java.
Проверьте себя
Мини-quiz · закрепить
Проверьте себя
Q1. Кто определяет Port — core или adapter?
Q2. Primary port — это...
Q3. В чём преимущество Port через Protocol (PEP 544) перед ABC?
Q4. Какой признак нарушения Hexagonal?
Q5. Когда Hexagonal избыточен?