Hexagonal Architecture — мощный подход, но с ценой. В этой статье разберёмся: что она даёт, когда эта цена оправдана, а когда лучше обойтись чем-то проще.
Что такое Hexagonal Architecture и почему она не для всех
Представьте обычный сервис: контроллер принимает запрос, вызывает сервис, тот идёт в базу. Просто и быстро. Но когда проект растёт — появляются новые интеграции, бизнес-правила усложняются, команда увеличивается — этот подход начинает давать трещины. Базовый слой зарастает Spring-зависимостями, бизнес-логика переплетается с кодом работы с базой и HTTP, тесты требуют полного подъёма Spring-контекста.
Hexagonal Architecture (она же ports and adapters) решает эту проблему за счёт жёсткого разделения: бизнес-логика живёт в отдельном core/ модуле, куда не пускают ни Spring, ни jOOQ, ни Jackson, ни клиентов внешних систем. Совсем пустым он не бывает: JDK, Lombok, аннотации jakarta.validation и собственные доменные библиотеки там есть — подробный список в статье Core слой. Всё, что связано с внешним миром (база данных, HTTP, Kafka, SMS), оформляется как адаптер, подключаемый через интерфейс-порт.
Цена этого решения: несколько gradle-модулей, дополнительные mapper-классы между слоями, ArchUnit-тесты для проверки границ. На простом сервисе эта цена не окупается. На сложном — окупается многократно.
Три раскладки модулей
Перед разговором о признаках «пора/рано» полезно понять общую шкалу. Речь тут только про раскладку модулей — не путайте её со шкалой зрелости CQRS из соседней статьи: там номера означают разделение чтения и записи, здесь — физические границы в сборке. Это две независимые оси.
- Всё в одном модуле — UseCase + Handler + Controller рядом. Без разделения на домен и инфраструктуру. Подходит для простых CRUD-сервисов и новых проектов.
- Домен выделен, модуль один — появляются агрегаты с бизнес-логикой, но всё ещё в одном-двух модулях. Физического разделения на
core/adapterнет. - Полный Hexagonal — DDD и порты вместе. Несколько gradle-модулей, порт-интерфейсы в
core/, адаптеры их реализуют, ArchUnit-тесты держат границы. Это то, про что статья.
Разница между второй и третьей раскладками — та, на которой и принимается решение, поэтому покажем все три деревом. Один и тот же сервис (оформление заказа с оплатой), одна и та же функциональность.
Всё в одном модуле — 4 файла на операцию:
src/main/java/ru/example/order/
├── OrderController.java REST + маппинг + вызов
├── OrderService.java правила + обращение к базе + вызов платёжки
├── Order.java сущность с аннотациями хранения
└── OrderRepository.java интерфейс хранилища из фреймворка
Домен выделен, модуль один — 7 файлов, границы пакетами:
src/main/java/ru/example/
├── domain/order/
│ ├── Order.java правила внутри, без аннотаций хранения
│ ├── OrderRepository.java интерфейс объявлен здесь (порт)
│ └── PaymentGateway.java интерфейс объявлен здесь (порт)
├── application/
│ └── PlaceOrderService.java сценарий: достать, позвать домен, сохранить
└── infrastructure/
├── web/OrderController.java
├── persistence/JdbcOrderRepository.java + OrderRecordMapper.java
└── payment/HttpPaymentGateway.java
Полная раскладка модулями — те же 7 логических частей, но в пяти модулях сборки, плюс преобразователи на каждой границе:
core/src/main/java/... (без зависимостей вообще)
├── domain/order/Order.java
├── domain/order/port/out/OrderRepository.java
├── domain/order/port/out/PaymentGateway.java
├── domain/order/port/in/PlaceOrderUseCase.java
└── application/PlaceOrderService.java
persistence/src/main/java/... (зависит только от core)
├── JooqOrderRepository.java
└── OrderRecordMapper.java
payment-out-adapter/src/main/java/...
├── HttpPaymentGateway.java
├── PaymentRequestMapper.java
└── dto/… структуры чужого API
rest-api/src/main/java/...
├── OrderController.java
├── OrderRequestMapper.java
└── dto/… структуры своего API
bootstrap/src/main/java/...
├── Application.java
├── BeansConfiguration.java сборка обработчиков и адаптеров
└── application.yml
В чём практическая разница между второй и третьей. Логическая структура одинаковая: те же порты, те же преобразователи, то же правило зависимостей. Отличается чем проверяется граница и сколько преобразователей появляется:
| Домен выделен, один модуль | Модули сборки | |
|---|---|---|
| Чем держится граница | соглашением + тестом архитектуры | не собирается, если нарушить |
| Кто может импортировать что | технически — всё что угодно | только объявленные зависимости |
| Преобразователи | на границе с внешним миром | плюс между модулями, где структуры не общие |
| Сборка ядра отдельно | нет | да, и тесты ядра идут за секунды |
| Число файлов сборки | один | по одному на модуль плюс общие настройки |
| Цена изменения поля | 4–5 файлов | 7–9 файлов |
Когда второй раскладки достаточно, а третья не нужна: одна команда; тест архитектуры уже стоит и красит сборку; сроки прогона тестов устраивают. Это самый недооценённый вариант: он даёт почти всю пользу (чистое ядро, порты, тестируемость правил без инфраструктуры) при цене, близкой к обычному сервису.
Когда нужна третья: несколько команд правят один сервис (тогда «не собирается» надёжнее «не положено»); ядро должно собираться и тестироваться отдельно, потому что прогон всего занимает минуты; или адаптеров много и они с несовместимыми зависимостями (две версии одной библиотеки в разных адаптерах — модули решают это, пакеты нет).
Прыгать из первой раскладки сразу в третью — как переезжать в новый офис с тремя переговорными, когда в команде двое.
Три раскладки по возрастанию строгости: средняя стоит почти как обычный сервис и закрывает большинство спорных случаев.
Счёт: сколько файлов на одно изменение
Общие слова про «дополнительные mapper-классы» превращаются в решение, только когда их посчитали. Добавляем в уведомление одну строку текста — поле comment в заказе, которое надо показать в ответе.
| Раскладка | Что трогаем | Файлов |
|---|---|---|
| Всё в одном | сущность, ответ контроллера, миграция | 3 |
| Домен выделен | доменный объект, запись базы + преобразователь, структура ответа, миграция | 5 |
| Модули | доменный объект, команда, структура ответа ядра, запись базы + преобразователь в хранилище, структура и преобразователь входящего адаптера, миграция | 7–9 |
И то же самое для новой операции целиком («отменить заказ»):
| Раскладка | Файлов |
|---|---|
| Всё в одном | 1–2 (метод в сервисе, метод в контроллере) |
| Домен выделен | 3–4 (метод домена, сценарий, метод контроллера, тест) |
| Модули | 6–8 (метод домена, команда, обработчик, входной порт, метод контроллера, структуры запроса и ответа, преобразователи, тесты) |
Как этим пользоваться. Умножьте на число изменений в квартал. Сервис, где в квартал двадцать мелких правок, в модульной раскладке стоит примерно на восемьдесят-сто правок файлов дороже — это несколько дней работы, которые вы платите за границы. Если взамен вы получаете тесты правил, идущие секунды вместо минут (а прогон происходит десятки раз в день), и невозможность смешать слои при пяти разработчиках — сделка выгодная. Если правок в квартал двести, а разработчика два — нет.
Это и есть та цена, которую надо называть вслух, когда команда обсуждает переход. Не «архитектура сложнее», а «одно поле — семь файлов вместо трёх, зато тесты домена за две секунды и границу нельзя нарушить».
Вот сигналы, при которых Hexagonal начинает давать измеримый выигрыш. Если совпадает хотя бы три из пяти — время переходить.
Сервис интегрируется с несколькими внешними системами. Типичный набор — PostgreSQL, платёжный провайдер, Kafka, SMS-шлюз. У каждой системы свой формат данных, свои настройки повторных попыток и таймаутов, своя модель сбоев. Без явных адаптеров всё это смешивается в одном сервис-классе и растёт неуправляемо.
Доменная логика становится сложной. Появляются агрегаты с инвариантами — например, Order.confirm() проверяет пять условий и генерирует три события. Бизнес-правила перетекают между сценариями. Домен хочется изолировать от инфраструктуры: менять бизнес-правила, не трогая Spring-конфиги.
Три и более способа входа. REST для пользователя, REST для администратора, отложенные задачи, Kafka-потребители — каждый со своей моделью безопасности. Без выделенных *-in-adapter/ модулей они начинают пересекаться по конфигурации и SecurityFilterChain.
Тесты требуют подъёма всего Spring-контекста. Если для проверки бизнес-логики нужен @SpringBootTest — потому что доменный сервис зависит от Spring-аннотированных классов — то чистый core/ в Hexagonal решает это. Юнит-тесты на агрегатах без Spring работают за миллисекунды.
Команда из трёх и более разработчиков. Когда несколько человек редактируют один сервис, архитектурные границы становятся социальной потребностью. ArchUnit-тест поймает ситуацию «новый разработчик положил Spring-импорт в core», когда ревью пропустит. На команде из одного-двух человек устные договорённости работают без дополнительных тестов.
Признаки «рано»
И наоборот — когда переход даст больше боли, чем пользы.
Один сервис с одной базой данных и несложным доменом. Если внешний мир — только PostgreSQL, а правил в домене немного, repository-pattern в одном модуле справляется с разделением домена и хранилища. Оговорка про домен тут обязательна: сервис с одной базой, но с богатыми правилами упирается в признак «тесты требуют подъёма всего Spring-контекста» — и тогда одна база не аргумент против. Признак «рано» не перевешивает признак «пора», он лишь снимает тот случай, когда переносить особо нечего.
Маленькая команда, небольшой сервис. Один-два разработчика, до десяти тысяч строк кода. Конвенции держатся устно. Архитектурные тесты ради «а вдруг кто-то что-то не туда положит» — избыточная работа, когда core — три файла.
Бизнес-логика ещё меняется и ищет форму. На старте проекта бизнес-модель скачет. Hexagonal с её mapper-классами и портами тормозит итерации: каждое «попробуем по-другому» — переписывание нескольких mapper-ов и портов. Сначала нужно найти устойчивую форму, потом — Hexagonal.
Нет агрегатов с инвариантами. Если домен — это просто таблица с базовыми CRUD-методами, Hexagonal-разделение ничего не защищает. Анемичный домен в Hexagonal-обёртке — самый дорогой вариант анемичного домена.
Когда совпали и «пора», и «рано»
Типичный случай, который два независимых списка не закрывают: три признака «пора» и два признака «рано» одновременно. Например: три внешние системы и сложный домен (пора), но команда из двух человек и бизнес-логика ещё ищет форму (рано). Правило разрешения такое.
Признаки «рано» — не противовес, а вето по конкретному пункту. Они не вычитаются из «пора»: каждый снимает свою причину перехода, а не общий счёт.
- «Один сервис, одна база, несложный домен» снимает причину «много внешних систем» — если систем действительно одна. Если их три, этот признак «рано» просто неверен и в счёт не идёт.
- «Маленькая команда» снимает причину «границы как социальная потребность», но не снимает «тесты требуют подъёма контекста».
- «Бизнес-логика ищет форму» — самое сильное вето: оно снимает все причины сразу, потому что любая раскладка будет переделана вместе с моделью. Это единственный признак «рано», который перебивает любое число «пора».
- «Нет агрегатов с инвариантами» снимает причину «сложный домен» — и одновременно означает, что защищать нечего.
Отсюда практический порядок разрешения конфликта:
- Логика ещё ищет форму? Ждём. Ничего не решаем, кроме того, что держим домен в отдельных пакетах, чтобы потом было проще.
- Нечего защищать (нет правил)? Не берём, независимо от числа интеграций: адаптеры можно завести и без всей раскладки.
- Остальные конфликты решаются в пользу второй раскладки — домен выделен пакетами, порты есть, модулей нет. Это ровно тот ответ, который закрывает большинство спорных случаев: он снимает оба возражения (дёшево для маленькой команды, легко переделать при смене модели) и даёт главную пользу.
- Третью раскладку берут, когда «рано» не осталось и есть хотя бы одна причина, которую пакетами не закрыть: несколько команд, несовместимые зависимости адаптеров, требование к скорости сборки ядра.
Полезная формулировка на будущее: признаки «пора» отвечают на вопрос «что мы получим», признаки «рано» — на вопрос «сможем ли мы это удержать». Получить много и не удержать хуже, чем получить меньше и удержать.
Две частые ошибки при внедрении Hexagonal.
Культ карго (cargo-cult) — все сервисы команды причёсаны под один шаблон независимо от их сложности. Сервис из трёх эндпоинтов в Hexagonal-раскладке даёт пять gradle-модулей, восемь mapper-классов и ArchUnit-тесты — ради чего? Это «архитектура ради архитектуры». Решение о применении принимается на уровне каждого сервиса, а не команды. Один и тот же отдел может держать простой сервис в одном модуле рядом с биллинговым в полной гексагональной раскладке.
Частичный Hexagonal — есть core/, но *-in-adapter/ смешан с REST-контроллерами и бизнес-логикой. Или есть persistence/, но вызовы к платёжному провайдеру лежат прямо в обработчике команды.
Почему это плохо:
- Гарантии компилятора не работают. Если хотя бы один модуль смешивает слои, ArchUnit ловит часть нарушений, а другую — нет. Команда теряет уверенность в чистоте кода.
- Читабельность хуже, чем у монолита. Разработчик каждый раз думает: «а тут уже Hexagonal или ещё нет?»
- Незавершённый рефакторинг накапливается. «Доделать когда-нибудь» обычно не делается: такая переделка идёт неделями, а не днями, и плохо помещается между обычными задачами. Сколько именно — зависит от объёма кода, числа внешних интеграций и от того, есть ли тесты, на которые можно опереться; на сервисе в десять тысяч строк с двумя интеграциями это недели две, на большом биллинге — месяцы.
Правило: либо полный Hexagonal (все модули, ArchUnit-тесты в CI, mapper-классы), либо никакого. Промежуточные состояния допустимы только как короткий переходный период с явным сроком завершения.
Как ведут этот переход
Правило без плана невыполнимо, поэтому вот план. Порядок важен: он подобран так, чтобы каждый шаг был полезен сам по себе и чтобы в любой момент можно было остановиться, не оставив кашу.
Шаг 1. Выделить ядро пакетами, не трогая сборку. Создаём domain и application, переносим туда доменные объекты и сценарии, объявляем интерфейсы портов в домене. Адаптеры остаются где были. Это дни работы, и после него уже есть чистое ядро и тесты правил без контекста.
Шаг 2. Поставить тест архитектуры на то, что уже верно. Правило «domain не импортирует фреймворк и инфраструктуру» — и оно зелёное с первого дня, потому что вы только что это сделали. С этого момента структура не размывается. Как написать — в статье про тесты архитектуры; если нарушений сотни, там же описан способ зафиксировать текущее состояние и запретить новые.
Шаг 3. Вынести ядро в модуль сборки. Первый модуль — только core, всё остальное пока остаётся одним модулем «приложение». Сборка сразу проверяет, что ядро ни от кого не зависит: если не собирается, значит шаг 1 сделан не до конца, и это полезная находка.
Шаг 4. Выносить адаптеры по одному, начиная с самого автономного. Обычно это внешняя система (платёжный клиент): у него мало связей, и он выносится за день. Хранилище — обычно последним, потому что оно связано со всем.
Шаг 5. Модуль сборки — последним. Он появляется, когда модулей стало больше двух: туда уезжает точка входа, настройки и сборка бинов.
Пять шагов перехода по порядку: после каждого сервис выкатывается, и остановиться можно на любом, не оставив кашу.
Как жить с двумя раскладками одновременно. Это и есть то самое «промежуточное состояние», и держать его терпимо, если соблюдены два условия. Первое: новый код пишется только по новой раскладке — никаких исключений, иначе граница не появится никогда. Второе: у перехода есть владелец и написанный срок («до конца квартала все адаптеры вынесены»), и оставшееся видно списком в репозитории — тогда это работа с известным концом, а не вечное «доделаем».
Чем страхуются от откатов. Три вещи: тест архитектуры (не даёт вернуться назад молча), перенос без изменения поведения (шаги 1–5 не меняют логику, значит существующие тесты остаются зелёными — а если их нет, сначала пишут тесты, потом переносят), и перенос по частям в отдельных изменениях, а не одним большим: одно изменение — один перенесённый адаптер, ревью читаемое, откатывается тоже частями.
Чего не делают: не начинают с модулей сборки (получите пустые модули и месяц переноса без пользы), не переносят всё одним изменением (ревью невозможно, откат невозможен), и не оставляют переход без срока — именно так рождается «частичный гексагон», о котором раздел выше.
Глубже: перевод трёхслойного сервиса в core, adapters и bootstrap по шагамрасширенное
Вся линия статей написана для проекта с чистого листа, а обычная ситуация другая: сервис уже работает, у него controller, service, repository в одном модуле, и остановить разработку на месяц нельзя. Перевод делают шагами, и на каждом шаге сервис выкатывается.
Шаг 0, зафиксировать текущее. Тесты на архитектуру с правилами «как есть»: слои не ходят вверх, циклов нет. Правила, которые сейчас нарушаются, замораживают (ArchUnit умеет запоминать существующие нарушения и падать только на новых), и с этого момента хуже не становится.
Шаг 1, ядро как пакет. Внутри того же модуля заводят пакет core и переносят туда доменные объекты и логику из сервисов, по одному сценарию: команда, обработчик, агрегат. Правило ArchUnit: core не импортирует Spring, jOOQ, Jackson и HTTP. Интерфейсы репозиториев переезжают в core, их реализации остаются в старом месте. Старые сервисы становятся тонкими и зовут обработчики; удалять их не обязательно.
Шаг 2, out-адаптеры. Реализации репозиториев и клиентов к внешним системам переезжают в пакеты adapter.persistence, adapter.payment, и правило запрещает им зависеть друг от друга. Здесь всплывают маппинги между сущностями и сгенерированными классами, которые раньше жили в сервисах.
Шаг 3, in-адаптеры. Контроллеры переезжают в adapter.rest, начинают зависеть от диспетчера команд, а не от классов обработчиков, и перестают содержать логику.
Шаг 4, модули Gradle. Только когда пакеты чистые: пакет превращается в модуль механически, а компилятор становится вторым стражем после ArchUnit. Bootstrap выделяют последним, туда уезжают конфигурация и главный класс.
Что делать с наполовину переведённым сервисом: жить в нём спокойно, потому что каждый шаг это работающее состояние, и новые сценарии пишут сразу в новой раскладке, а старые переносят по мере правок. Откат любого шага это откат коммитов, потому что схема базы и контракты не менялись. Длится это месяцы, а не спринт, и правило «либо полностью, либо никак» относится к каждому сценарию, а не ко всему сервису разом.
Коротко
- Hexagonal Architecture изолирует бизнес-логику в
core/, куда не пускают Spring, jOOQ, Jackson и клиентов внешних систем; цена — дополнительные модули, mapper-классы, ArchUnit-тесты. Переходить стоит, если совпадает хотя бы три из пяти признаков: несколько внешних интеграций, сложная доменная логика, три и более способа входа, тесты требуют Spring-контекста, команда три и более человек. - Не стоит переходить при одной базе данных и несложном домене, маленькой команде, нестабильной бизнес-логике или отсутствии агрегатов с инвариантами.
- Культ карго (Hexagonal для всех сервисов) — антипаттерн. Решение принимается на уровне каждого сервиса.
- Частичный Hexagonal хуже монолита: теряешь гарантии, но получаешь всю сложность.
- Живой сервис переводят шагами с выкатом на каждом: заморозить нарушения ArchUnit, ядро как пакет по одному сценарию, out-адаптеры, in-адаптеры, модули Gradle последними; новое пишут сразу в новой раскладке.
- Вторая раскладка (домен пакетами, порты есть, модулей нет) даёт почти всю пользу при цене обычного сервиса; модули сборки нужны для нескольких команд, отдельной сборки ядра и несовместимых зависимостей адаптеров.
- Счёт цены: одно поле — 3 файла в одном модуле, 5 при выделенном домене, 7–9 в модулях; новая операция — 1–2, 3–4 и 6–8; умножайте на число правок в квартал.
- Признаки «рано» не вычитаются из «пора», а снимают конкретные причины; «логика ищет форму» перебивает всё, «нечего защищать» тоже, остальные конфликты решаются в пользу второй раскладки.
- Переход ведут по шагам, каждый полезен сам: ядро пакетами, тест архитектуры на уже верное, модуль ядра, адаптеры по одному начиная с автономного, модуль сборки последним.
- Смешанное состояние терпимо при двух условиях: новый код только по новой раскладке и у перехода есть владелец с написанным сроком; переносят частями, без изменения поведения, под зелёными тестами.
Что почитать дальше
- Структура модулей — что именно строим, когда решили переходить.
- Core слой — что попадает в
core/и почему. - Гексагональная архитектура: порты и адаптеры - разбор идеи целиком, если решение ещё не принято.
- Тесты архитектуры - чем машина держит границу, на которую эта статья ссылается пять раз.