← назад к разделу

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/ и почему.