Гексагональная архитектура (Hexagonal Architecture)
Ставит предметную область в центр: ядро объявляет порты, а база, платёжная система, HTTP и почта подключаются к ним адаптерами снаружи.
Проблема
В слоистой архитектуре сервис заказов зависит от слоя данных напрямую:
final readonly class OrderService
{
public function __construct(
private ProductTable $products, // concrete table gateway
private OrderTable $orders,
private PaymentProviderSdk $payments, // concrete payment SDK
private SmtpMailer $mailer,
) {}
public function placeOrder(string $customerId, array $items): int
{
$row = $this->products->findBySku($items[0]['sku']);
if ($row['stock'] < $items[0]['quantity']) { /* ... */ }
$id = $this->orders->insert(['customer_id' => $customerId, 'status' => 'pending']);
$this->payments->createPayment(['amount' => ['value' => '640.00', 'currency' => 'RUB']]);
// ...
}
}Правило "нет товара - нет заказа" написано через колонки таблицы, формат денег продиктован SDK платёжной системы. Сменили провайдера оплаты или перенесли склад в соседний сервис - правится сценарий заказа. Тест сценария тянет за собой тестовую базу и заглушку HTTP.
Решение
Развернуть зависимости: не ядро зависит от инфраструктуры, а инфраструктура от ядра.
- Ядро - доменная модель (
Order,OrderLine,Money,OrderStatus) и сервисOrderServiceсо сценариемplaceOrder. Ядро не знает ни про SQL, ни про HTTP. - Порты - интерфейсы, которые ядро объявляет на своём языке:
OrderRepository(выдать номер, сохранить, найти заказ),Inventory(остаток и цена товара),PaymentGateway(списать сумму),Notifier(сообщить, что заказ оформлен). Это ведомые порты: через них ядро само ходит наружу. - Адаптеры - реализации портов снаружи:
InMemoryOrderRepository,InMemoryInventory,FakePaymentGateway,RecordingNotifier. В проекте рядом лежат SQL-репозиторий, клиент платёжной системы и отправка писем, а ядро от замены не меняется. - Ведущий адаптер
HttpOrderAdapterс другой стороны: переводит HTTP-запрос в вызовplaceOrderи ошибки ядра в статусы. Ведущий порт здесь - публичный методOrderService::placeOrder; отдельный интерфейс для него заводят, когда входов несколько и их хочется тестировать с подменой ядра.
Форма шестиугольника ничего не значит: у ядра много сторон, к каждой подключается свой адаптер. Важно одно правило - все зависимости в исходном коде направлены внутрь, к ядру.
Структура
Слева ведущий адаптер вызывает ядро, справа ведомые адаптеры реализуют порты ядра. Стрелки из адаптеров смотрят внутрь, из ядра наружу нет ни одной. Порт описывает то, что нужно сценарию, а не то, что умеет внешняя система: charge(customerId, Money) вместо полного API платёжного провайдера, orderPlaced(Order) вместо send(to, subject, html). Как превратить заказ в письмо, решает адаптер.
Как это собирается
Порты и адаптеры встречаются в одном месте - в composition root: в DI-контейнере, main или фабрике приложения. Там создаются адаптеры (InMemoryOrderRepository, InMemoryInventory с товарами, FakePaymentGateway с лимитом суммы, RecordingNotifier), из них ядро OrderService, и ядро отдаётся ведущему адаптеру HttpOrderAdapter. Это единственное место, которое знает все конкретные классы; смена платёжной системы - одна строка здесь.
Первый запрос проходит весь путь: адаптер собирает Item из тела запроса, ядро спрашивает Inventory про остатки и цены, берёт номер у OrderRepository, сохраняет заказ pending, списывает сумму через PaymentGateway, сохраняет paid и сообщает Notifier; адаптер отвечает 201. Во втором запросе ручек (pen) не хватает: ядро бросает OutOfStock, не трогая остальные порты, ответ 409. В третьем шлюз отказывает: заказ сохранён как cancelled, Notifier молчит, ответ 402.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Код каждого языка - один файл: ядро, порты, ведущий адаптер, ведомые адаптеры. В проекте ядро и адаптеры живут в разных пакетах, и пакет ядра не импортирует ничего из адаптеров.
| Язык | Особенность |
|---|---|
| PHP | порты - interface, статус - enum OrderStatus, в тесте отказывающий шлюз - анонимный класс |
| Go | порты - маленькие интерфейсы, которые адаптеры реализуют неявно; склад в памяти - просто map с методами |
| Python | порты - Protocol: адаптеры не наследуют их, а совпадают по форме; статус - StrEnum |
| TypeScript | порты - interface, отказывающий шлюз в тесте - объектный литерал { charge: () => false } |
Запуск: make test T=architecture/hexagonal
Плюсы и минусы
Тесты проверяют ядро через порты, без базы и HTTP: успешный заказ прошёл через все четыре порта; при нехватке товара ни один порт, кроме склада, не тронут (нет записи, списания и уведомления); при отказе оплаты заказ cancelled. Отказывающий шлюз подменяется прямо в тесте - это и есть проверка, что порт настоящий. Отдельный тест гоняет ведущий адаптер и сверяет статусы ответа.
- ✅ Правила не зависят от инфраструктуры: сменить базу или платёжную систему - написать новый адаптер.
- ✅ Ядро тестируется быстро и целиком, адаптеры в памяти пишутся за минуты.
- ✅ Входов может быть сколько угодно (HTTP, консоль, очередь), все зовут одно ядро.
- ❌ Больше типов: на каждую внешнюю систему порт и минимум два адаптера.
- ❌ Порты надо проектировать: слишком узкий порт заставит менять ядро при каждом новом сценарии, слишком широкий повторит API внешней системы.
- ❌ Адаптеры в памяти могут разойтись с настоящими (транзакции, уникальность), поэтому для SQL-адаптера нужны свои интеграционные тесты.
Когда не применять
- Логики почти нет, приложение перекладывает данные из формы в таблицу - порты будут повторять CRUD, хватит слоистой архитектуры.
- Внешняя система одна и меняться не будет, а тестам достаточно тестовой базы - порт ради порта.
- Скрипт или прототип, который проживёт неделю.
Отличия между языками
- PHP - порты объявляются явно, адаптер пишет
implements. Анонимный класс позволяет подменить порт в тесте без отдельного файла. - Go - интерфейсы реализуются неявно, поэтому идиома Go - объявлять интерфейс у потребителя, то есть в ядре. Это и есть порт: адаптер в другом пакете ничего о нём не знает, но подходит. Склад в памяти -
mapс методами, без структуры-обёртки. - Python -
Protocolдаёт ту же неявную реализацию: адаптеры не наследуют порты, анализатор типов сверяет их по форме. Без анализатора это просто документация, утиная типизация работает и так. - TypeScript - структурная типизация: любой объект с методом
chargeужеPaymentGateway, поэтому заглушка в тесте - литерал в одну строку.implementsу адаптеров оставлен, чтобы компилятор ловил расхождение с портом в месте объявления.
Связанные темы
- Слоистая архитектура (Layered Architecture) - тот же заказ с зависимостью от слоя данных, которую здесь развернули
- Чистая архитектура (Clean Architecture) - те же порты, плюс отдельный объект на сценарий, запрос, ответ и презентер
- Репозиторий (Repository) - самый частый ведомый порт
- Сервисный слой (Service Layer) -
OrderServiceи есть сервисный слой внутри ядра - Тактический DDD (Domain-Driven Design) - как устроить само ядро: агрегаты, объекты-значения, доменные события