Hexagonal Architecture — мощный подход, но с ценой. В этой статье разберёмся: что она даёт, когда эта цена оправдана, а когда лучше обойтись чем-то проще.
Что такое Hexagonal Architecture и почему она не для всех
Представьте обычный сервис: контроллер принимает запрос, вызывает сервис, тот идёт в базу. Просто и быстро. Но когда проект растёт — появляются новые интеграции, бизнес-правила усложняются, команда увеличивается — этот подход начинает давать трещины. Базовый слой зарастает Spring-зависимостями, бизнес-логика переплетается с кодом работы с базой и HTTP, тесты требуют полного подъёма Spring-контекста.
Hexagonal Architecture (она же ports and adapters) решает эту проблему за счёт жёсткого разделения: бизнес-логика живёт в чистом core/ модуле без каких-либо фреймворк-зависимостей. Всё, что связано с внешним миром (база данных, HTTP, Kafka, SMS), оформляется как адаптер, подключаемый через интерфейс-порт.
Цена этого решения: несколько gradle-модулей, дополнительные mapper-классы между слоями, ArchUnit-тесты для проверки границ. На простом сервисе эта цена не окупается. На сложном — окупается многократно.
Три уровня зрелости сервиса
Перед разговором о признаках «пора/рано» полезно понять общую шкалу. Сервисы бывают разными по сложности, и подход к архитектуре выбирается под уровень:
- Уровень 1 — UseCase + Handler + Controller в одном модуле. Без разделения на домен и инфраструктуру. Подходит для простых CRUD-сервисов и новых проектов.
- Уровень 2 — появляются агрегаты с бизнес-логикой, но всё ещё в одном-двух модулях. Домен выделен, но физического разделения на
core/adapterнет. - Уровень 3 — DDD и Hexagonal вместе. Несколько gradle-модулей, порт-интерфейсы в
core/, адаптеры их реализуют, ArchUnit-тесты держат границы. Это то, про что статья.
Переходить на Уровень 3 с Уровня 1 — как переезжать в новый офис с тремя переговорными, когда в команде двое.
Признаки «пора»
Вот сигналы, при которых Hexagonal начинает давать измеримый выигрыш. Если совпадает хотя бы три из пяти — время переходить.
Сервис интегрируется с несколькими внешними системами. Типичный сетап — PostgreSQL, платёжный провайдер, Kafka, SMS-шлюз. У каждой системы свой формат данных, свои настройки повторных попыток и таймаутов, своя модель сбоев. Без явных адаптеров всё это смешивается в одном сервис-классе и растёт неуправляемо.
Доменная логика становится сложной. Появляются агрегаты с инвариантами — например, Order.confirm() проверяет пять условий и генерирует три события. Бизнес-правила перетекают между сценариями. Домен хочется изолировать от инфраструктуры: менять бизнес-правила, не трогая Spring-конфиги.
Три и более способа входа. REST для пользователя, REST для администратора, отложенные задачи, Kafka-потребители — каждый со своей моделью безопасности. Без выделенных *-in-adapter/ модулей они начинают пересекаться по конфигурации и SecurityFilterChain.
Тесты требуют подъёма всего Spring-контекста. Если для проверки бизнес-логики нужен @SpringBootTest — потому что доменный сервис зависит от Spring-аннотированных классов — то чистый core/ в Hexagonal решает это. Юнит-тесты на агрегатах без Spring работают за миллисекунды.
Команда из трёх и более разработчиков. Когда несколько человек редактируют один сервис, архитектурные границы становятся социальной потребностью. ArchUnit-тест поймает ситуацию «новый разработчик положил Spring-импорт в core», когда ревью пропустит. На команде из одного-двух человек устные договорённости работают без дополнительных тестов.
Признаки «рано»
И наоборот — когда переход даст больше боли, чем пользы.
Один сервис с одной базой данных. Если внешний мир — только PostgreSQL, Hexagonal не нужна. Repository-pattern в одном модуле справляется с разделением домена и хранилища.
Маленькая команда, небольшой сервис. Один-два разработчика, до десяти тысяч строк кода. Конвенции держатся устно. Архитектурные тесты ради «а вдруг кто-то что-то не туда положит» — избыточная работа, когда core — три файла.
Бизнес-логика ещё меняется и ищет форму. На старте проекта бизнес-модель скачет. Hexagonal с её mapper-классами и портами тормозит итерации: каждое «попробуем по-другому» — переписывание нескольких mapper-ов и портов. Сначала нужно найти устойчивую форму, потом — Hexagonal.
Нет агрегатов с инвариантами. Если домен — это просто таблица с базовыми CRUD-методами, Hexagonal-разделение ничего не защищает. Анемичный домен в Hexagonal-обёртке — самый дорогой вариант анемичного домена.
Ловушка: cargo-cult и частичный подход
Две частые ошибки при внедрении Hexagonal.
Cargo-cult — все сервисы команды причёсаны под один шаблон независимо от их сложности. Сервис из трёх эндпоинтов в Hexagonal-раскладке даёт пять gradle-модулей, восемь mapper-классов и ArchUnit-тесты — ради чего? Это «архитектура ради архитектуры». Решение о применении принимается на уровне каждого сервиса, а не команды. Один и тот же отдел может иметь простой сервис Уровня 1 рядом с биллинговым сервисом Уровня 3.
Частичный Hexagonal — есть core/, но *-in-adapter/ смешан с REST-контроллерами и бизнес-логикой. Или есть persistence/, но вызовы к платёжному провайдеру лежат прямо в обработчике команды.
Почему это плохо:
- Compile-time гарантии не работают. Если хотя бы один модуль смешивает слои, ArchUnit ловит часть нарушений, а другую — нет. Команда теряет уверенность в чистоте кода.
- Читабельность хуже, чем у монолита. Разработчик каждый раз думает: «а тут уже Hexagonal или ещё нет?»
- Незавершённый рефакторинг накапливается. «Доделать когда-нибудь» обычно не делается — рефакторинг занимает две-четыре недели и плохо помещается в обычный цикл разработки.
Правило: либо полный Hexagonal (все модули, ArchUnit-тесты в CI, mapper-классы), либо никакого. Промежуточные состояния допустимы только как короткий переходный период с явным сроком завершения.
Коротко
- Hexagonal Architecture изолирует бизнес-логику в чистом
core/без фреймворк-зависимостей; цена — дополнительные модули, mapper-классы, ArchUnit-тесты. - Переходить стоит, если совпадает хотя бы три из пяти признаков: несколько внешних интеграций, сложная доменная логика, три и более способа входа, тесты требуют Spring-контекста, команда три и более человек.
- Не стоит переходить при одной базе данных, маленькой команде, нестабильной бизнес-логике или отсутствии агрегатов с инвариантами.
- Cargo-cult (Hexagonal для всех сервисов) — антипаттерн. Решение принимается на уровне каждого сервиса.
- Частичный Hexagonal хуже монолита: теряешь гарантии, но получаешь всю сложность.
Что почитать дальше
- Структура модулей — что именно строим, когда решили переходить.
- Core слой — что попадает в
core/и почему.