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

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-обёртке — самый дорогой вариант анемичного домена.

Когда совпали и «пора», и «рано»

Типичный случай, который два независимых списка не закрывают: три признака «пора» и два признака «рано» одновременно. Например: три внешние системы и сложный домен (пора), но команда из двух человек и бизнес-логика ещё ищет форму (рано). Правило разрешения такое.

Признаки «рано» — не противовес, а вето по конкретному пункту. Они не вычитаются из «пора»: каждый снимает свою причину перехода, а не общий счёт.

  • «Один сервис, одна база, несложный домен» снимает причину «много внешних систем» — если систем действительно одна. Если их три, этот признак «рано» просто неверен и в счёт не идёт.
  • «Маленькая команда» снимает причину «границы как социальная потребность», но не снимает «тесты требуют подъёма контекста».
  • «Бизнес-логика ищет форму» — самое сильное вето: оно снимает все причины сразу, потому что любая раскладка будет переделана вместе с моделью. Это единственный признак «рано», который перебивает любое число «пора».
  • «Нет агрегатов с инвариантами» снимает причину «сложный домен» — и одновременно означает, что защищать нечего.

Отсюда практический порядок разрешения конфликта:

  1. Логика ещё ищет форму? Ждём. Ничего не решаем, кроме того, что держим домен в отдельных пакетах, чтобы потом было проще.
  2. Нечего защищать (нет правил)? Не берём, независимо от числа интеграций: адаптеры можно завести и без всей раскладки.
  3. Остальные конфликты решаются в пользу второй раскладки — домен выделен пакетами, порты есть, модулей нет. Это ровно тот ответ, который закрывает большинство спорных случаев: он снимает оба возражения (дёшево для маленькой команды, легко переделать при смене модели) и даёт главную пользу.
  4. Третью раскладку берут, когда «рано» не осталось и есть хотя бы одна причина, которую пакетами не закрыть: несколько команд, несовместимые зависимости адаптеров, требование к скорости сборки ядра.

Полезная формулировка на будущее: признаки «пора» отвечают на вопрос «что мы получим», признаки «рано» — на вопрос «сможем ли мы это удержать». Получить много и не удержать хуже, чем получить меньше и удержать.

Две частые ошибки при внедрении 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. Модуль сборки — последним. Он появляется, когда модулей стало больше двух: туда уезжает точка входа, настройки и сборка бинов.

Ядро пакетами домен и порты рядом Тест архитектуры фиксирует уже верное Модуль core собирается сам Адаптеры по одному начиная с автономного Модуль сборки последним

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

Как жить с двумя раскладками одновременно. Это и есть то самое «промежуточное состояние», и держать его терпимо, если соблюдены два условия. Первое: новый код пишется только по новой раскладке — никаких исключений, иначе граница не появится никогда. Второе: у перехода есть владелец и написанный срок («до конца квартала все адаптеры вынесены»), и оставшееся видно списком в репозитории — тогда это работа с известным концом, а не вечное «доделаем».

Чем страхуются от откатов. Три вещи: тест архитектуры (не даёт вернуться назад молча), перенос без изменения поведения (шаги 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; умножайте на число правок в квартал.
  • Признаки «рано» не вычитаются из «пора», а снимают конкретные причины; «логика ищет форму» перебивает всё, «нечего защищать» тоже, остальные конфликты решаются в пользу второй раскладки.
  • Переход ведут по шагам, каждый полезен сам: ядро пакетами, тест архитектуры на уже верное, модуль ядра, адаптеры по одному начиная с автономного, модуль сборки последним.
  • Смешанное состояние терпимо при двух условиях: новый код только по новой раскладке и у перехода есть владелец с написанным сроком; переносят частями, без изменения поведения, под зелёными тестами.

Что почитать дальше