handbookНаписать

architecture/hexagonal/README.md · тема · 4 языка · 36 тестов

Гексагональная архитектура (Hexagonal Architecture) ​

Ставит предметную область в центр: ядро объявляет порты, а база, платёжная система, HTTP и почта подключаются к ним адаптерами снаружи.

Проблема ​

В слоистой архитектуре сервис заказов зависит от слоя данных напрямую:

php
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; отдельный интерфейс для него заводят, когда входов несколько и их хочется тестировать с подменой ядра.

Форма шестиугольника ничего не значит: у ядра много сторон, к каждой подключается свой адаптер. Важно одно правило - все зависимости в исходном коде направлены внутрь, к ядру.

Структура ​

Ядро

placeOrder

implements

implements

implements

implements

HttpOrderAdapter

OrderService

Order

OrderRepository

Inventory

PaymentGateway

Notifier

InMemoryOrderRepository

InMemoryInventory

FakePaymentGateway

RecordingNotifier

Слева ведущий адаптер вызывает ядро, справа ведомые адаптеры реализуют порты ядра. Стрелки из адаптеров смотрят внутрь, из ядра наружу нет ни одной. Порт описывает то, что нужно сценарию, а не то, что умеет внешняя система: 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 у адаптеров оставлен, чтобы компилятор ловил расхождение с портом в месте объявления.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS