Гексагональная архитектура

Гексагональная архитектура (Hexagonal Architecture), её второе название - Порты и адаптеры (Ports and Adapters), ставит бизнес-правила в центр приложения и отделяет их от всего внешнего: интерфейса, базы данных, тестов. Ядро объявляет порты - интерфейсы, через которые с ним можно говорить и через которые оно само просит данные, - а снаружи к портам подключаются адаптеры. Все зависимости направлены внутрь, к ядру, поэтому интерфейс или хранилище можно заменить другим адаптером того же порта, не меняя ни строчки в правилах.

Проблема

В слоистой архитектуре бизнес-слой вызывает слой хранения и зависит от него: сервис регистрации знает репозиторий, а репозиторий - устройство базы. Правило «email должен быть уникальным» оказывается привязано к хранилищу: интерфейс репозитория определяет слой хранения, а не правила, и когда он меняется, вместе с ним меняются сервис и заглушки в его тестах. Ещё хуже со входами: форма, API и автотест хотят вызывать одни и те же правила, но слоистая схема рисует только один вход сверху и не говорит, как подключить остальные.

Решение

В центре - ядро с правилами предметной области. Оно объявляет два вида портов: ведущий порт (что ядро умеет - например, register и listEmails) и ведомый порт (что ядру нужно от мира - например, хранилище с методами has, add, all). Снаружи стоят адаптеры. Ведущие адаптеры (форма, тест, API) вызывают ведущий порт. Ведомые адаптеры (хранилище в памяти, localStorage, база) реализуют ведомый порт. Ядро не знает ни одного адаптера по имени, а адаптеры зависят от ядра: ведущие вызывают его порт, ведомые реализуют порт, который оно объявило. Поэтому адаптер можно заменить любым другим адаптером того же порта, не трогая ядро.

Как это работает

Паттерн придумал Алистер Кокберн и в 2005 году описал его в статье «Hexagonal (Ports & Adapters) Architecture» (технический отчёт HaT). Сам паттерн в статье называется Ports and Adapters, а «гексагональная архитектура» указана как второе имя - в итоге прижились оба. Цель он сформулировал так: позволить приложению одинаково управляться пользователями, программами, автотестами или пакетными скриптами и разрабатываться и тестироваться в изоляции от устройств и баз данных, с которыми оно будет работать. В 2024 году Кокберн и Хуан Мануэль Гарридо де Пас выпустили книгу «Hexagonal Architecture Explained» - подробное изложение паттерна от его автора.

Шестиугольник ничего не считает. Граней шесть не потому, что портов должно быть шесть: Кокберну нужна была фигура, на которой хватает места нарисовать столько портов и адаптеров, сколько понадобится, и которая не сводится к одномерной стопке слоёв с «верхом» и «низом». Главное на такой схеме - граница между внутренним и внешним: внутри ядро, снаружи все остальные, и порты по краям устроены одинаково.

Порт у Кокберна - это осмысленный разговор с приложением, оформленный как API: «зарегистрировать пользователя», «сохранить и найти адреса». Адаптер переводит этот API в сигналы конкретного устройства и обратно. К одному порту подключается сколько угодно адаптеров: в статье к одному порту подключены и графический интерфейс, и тестовый стенд FIT, а к порту базы - и настоящая база, и её заменитель в памяти.

Стороны шестиугольника несимметричны. Слева (или сверху) первичные (primary), или ведущие (driving), участники - те, кто запускает приложение: пользователь, тест, другая программа. Справа (или снизу) вторичные (secondary), или ведомые (driven), - те, кого приложение использует само: база, почта, внешний сервис. Ведущий адаптер вызывает порт, ведомый адаптер реализует порт. Направление вызова разное, а направление зависимости одно - внутрь.

В этом главное отличие от слоистой архитектуры. Там UserService получает UserRepository и зависит от слоя хранения. Здесь ядро само объявляет UserStorePort, а хранилище подстраивается под него - это инверсия зависимостей (Dependency Inversion Principle Роберта Мартина), применённая ко всей границе приложения. Код правил при этом почти не меняется: в примере register такой же, как в слоистом, а меняется только то, кто от кого зависит.

Порт проектируется по потребностям ядра, а не по возможностям технологии. Метод has(email) годится для Set, localStorage и SQL-базы, а метод query(sql) протащил бы базу прямо в ядро. Тонкость JavaScript: сетевое хранилище асинхронно, и если порт синхронный, адаптер для сервера к нему не подойдёт. Тогда меняют сам порт (методы возвращают Promise, ядро пишет await) - это правка ядра, и её делают осознанно, один раз для всех адаптеров.

У гексагональной архитектуры есть близкие родственники. Луковая архитектура (Джеффри Палермо, 2008) рисует те же зависимости внутрь как концентрические кольца и добавляет кольца внутри ядра: модель предметной области, доменные сервисы, прикладные сервисы. Чистая архитектура (Роберт Мартин, 2012) объединяет эти идеи под одним правилом зависимостей: код зависит только от того, что ближе к центру. Марк Симан в 2013 году показал, что при последовательном применении принципа инверсии зависимостей все три схемы приводят к одному и тому же коду. Отличие гексагональной в том, что она говорит только о границе ядра и не предписывает, как устроено ядро внутри.

Нюансы выбора

Следующий шаг после слоёв - если в слоистом приложении правила стали сложными, а тесты сервисов требуют базы или хитрых заглушек репозиториев, пора развернуть зависимость: объявить порт в ядре и сделать репозиторий его адаптером. Переход можно делать постепенно, по одному порту.

Много входов - веб-форма, публичный API, консольная утилита, обработчик очереди и автотест вызывают одни и те же правила. Каждый вход - отдельный ведущий адаптер, и ни один из них не дублирует проверки.

Порт на разговор, а не на таблицу - заводите порт под цель («хранилище пользователей», «отправка уведомлений»), а не под каждую таблицу или каждый внешний метод. Если портов стало столько же, сколько классов в ядре, граница проведена слишком мелко.

Против гексагона - CRUD-приложение без настоящих правил, одна база навсегда, короткий срок жизни. Ядро будет пустым, а порты и адаптеры - просто лишним слоем пересылки. Здесь проще обычная слоистая архитектура или готовый CRUD-фреймворк.

Гексагон, лук или чистая - если нужна только граница (ядро отдельно от инфраструктуры), хватает гексагональной. Если команде нужна ещё и готовая схема слоёв внутри ядра, луковая и чистая архитектуры дают её прямо, сохраняя то же правило зависимостей.

Примеры и история

Alistair Cockburn, «Hexagonal (Ports & Adapters) Architecture» (2005) - исходная статья: цель паттерна, порты и адаптеры, первичные и вторичные участники, тестовый стенд FIT и база в памяти как адаптеры.

Alistair Cockburn, Juan Manuel Garrido de Paz, «Hexagonal Architecture Explained» (2024) - книга автора паттерна с подробным разбором портов, адаптеров, тестов и типичных ошибок применения.

Jeffrey Palermo, «The Onion Architecture» (2008) - серия статей, где те же зависимости внутрь нарисованы кольцами и добавлены слои внутри ядра.

Robert C. Martin, «The Clean Architecture» (2012) - статья, объединившая гексагональную, луковую и похожие схемы под одним правилом зависимостей; позже развёрнута в книгу «Clean Architecture» (2017).

Mark Seemann, «Layers, Onions, Ports, Adapters: it's all the same» (2013) - статья о том, что последовательная инверсия зависимостей превращает слоистое приложение в луковое и гексагональное.

Когда применять

  • Когда бизнес-правила сложные и живут долго, а входов и хранилищ несколько или они будут меняться: веб-форма и API, база в продакшене и память в тестах.
  • Когда правила нужно быстро проверять автотестами, не поднимая базу и не открывая браузер, например при разработке через тесты (TDD).

Примеры из практики

  • Netflix - в техническом блоге команда описала (статья «Ready for changes with Hexagonal Architecture»), как построила сервис по портам и адаптерам, чтобы менять источники данных, не трогая бизнес-логику.
  • Domain-Driven Design - Вон Вернон в книге «Implementing Domain-Driven Design» (2013) предлагает гексагональную архитектуру как основу для приложения с богатой моделью предметной области: модель в центре, а интерфейсы, сообщения и хранилища подключаются через адаптеры.

Похожие паттерны

Слоистая архитектура

Слоистая архитектура (Layered Architecture) делит приложение на горизонтальные слои, у каждого из которых одна техническая роль: показать данные пользователю, применить бизнес-правила, сохранить и прочитать данные. Слой обращается только к слою под ним, а нижние слои ничего не знают о верхних. Поэтому любой слой можно понять, протестировать и заменить, не разбираясь в остальных.

Архитектура микроядра

Архитектура микроядра (Microkernel Architecture), её также называют архитектурой плагинов (plug-in architecture), делит приложение на маленькое ядро с минимумом функций и подключаемые к нему плагины. Ядро знает, какие плагины установлены, и вызывает их через общий контракт, а каждая новая функция или правило продукта приходит отдельным плагином. Поэтому возможности продукта растут и настраиваются под конкретного клиента подключением и отключением плагинов, а не правкой ядра - пока новый плагин укладывается в контракт ядра.

Клиент-серверная архитектура

Клиент-серверная архитектура (Client-Server Architecture) делит систему на две роли, которые работают в разных процессах и обычно на разных машинах: клиенты отправляют запросы, а сервер их обрабатывает и отвечает. Данные и правила, которым можно доверять, живут на сервере, и он - единственный источник истины сразу для всех клиентов. Клиент бывает тонким или толстым, но последнее слово в том, чему можно доверять, всегда за сервером. Между ними лежит сеть, поэтому каждое сообщение сериализуется, идёт с задержкой и может потеряться.