Сервис растёт, и бизнес-логика незаметно срастается с инфраструктурой — кодом, который ходит в базу, шлёт сообщения в брокер и разбирает HTTP. В какой-то момент простую бизнес-операцию уже не протестировать без поднятой базы и Spring, а смена технологии хранения ломает код, который должен быть про бизнес. Hexagonal Architecture отвечает на это так: всю бизнес-логику собирают в один слой — core, который ничего не знает об инфраструктуре. Разберём, что входит в core, что туда не должно попасть и почему эта граница так важна.
Зачем вообще нужна такая граница
В обычном Spring-приложении граница между бизнес-логикой и инфраструктурой размыта. OrderService получает репозиторий jOOQ, вызывает orderRepository.fetchOne(...), сам же делает JSON-маппинг, отправляет событие в Kafka. Всё в одном месте.
Когда нужно написать тест — оказывается, что нельзя протестировать логику подтверждения заказа, не подняв Spring, базу данных и Kafka. Когда схема БД меняется — ломается логика прямо в OrderService. Когда нужно сменить Kafka на RabbitMQ — надо менять код, который, казалось бы, про бизнес.
Hexagonal Architecture решает это одним правилом: core не знает ни о чём инфраструктурном. Он не знает, что данные хранятся в PostgreSQL, что API — это REST, что сообщения идут через Kafka. Core говорит только «мне нужен репозиторий, который умеет сохранить заказ» — и описывает это как интерфейс. Как именно он реализован — дело адаптера.
Граница ядра проходит по портам: наружу торчат интерфейсы, а реализации остаются в адаптерах.
Что входит в core/
Типичная структура выглядит так:
core/src/main/java/<pkg>/
├── domain/
│ ├── orders/
│ │ ├── aggregate/Order.java
│ │ ├── entity/OrderItem.java
│ │ ├── valueobject/Money.java
│ │ ├── event/OrderConfirmedEvent.java
│ │ └── exception/OrderNotFoundException.java
│ └── port/out/
│ ├── OrderRepository.java
│ ├── PaymentPort.java
│ └── NotificationPort.java
├── usecase/
│ ├── command/CreateOrderCommand.java
│ ├── command/CreateOrderCommandHandler.java
│ ├── query/GetOrdersQuery.java
│ └── query/GetOrdersQueryHandler.java
└── dto/
Разберём каждую часть:
domain/<bc>/ — доменные объекты, сгруппированные по bounded context (заказы, клиенты, платежи). Внутри каждого контекста: агрегаты, сущности, value objects, события, исключения.
domain/port/out/ — исходящие port-интерфейсы. Это описание того, что core нужно от внешнего мира: «умей найти заказ», «умей принять оплату», «умей отправить уведомление». Реализации этих интерфейсов живут в адаптерах — persistence, http-client и т.д.
Про место портов есть развилка, и обе раскладки встречаются. Здесь порты лежат общей папкой рядом с контекстами: один domain/port/out/ на весь core. Второй вариант — положить порты внутрь контекста, domain/orders/port/out/OrderRepository.java; так делают, когда контекстов несколько и каждый хочет свой набор портов, не пересекающийся с соседями. Выбор одноразовый: решите в начале и держитесь, иначе половина портов окажется в одном месте, половина в другом.
usecase/ — пары Command/Query + Handler. Команды меняют состояние агрегата, query возвращают данные для чтения.
dto/ — внутренние application-DTO (records), которые передаются между use cases. Не путать с HTTP-DTO — те живут в адаптере.
Про эту папку стоит сказать больше, потому что в дереве она пустая, а вопрос «что туда кладут» возникает сразу. Внутренние структуры бывают трёх видов, и только один из них действительно нужен:
- Структуры ответа чтения (
OrderSummary,OrderListItem) — плоские записи, которые возвращают обработчики запросов. Это самое частое содержимое папки, и у них ясная роль: они не доменные объекты (правил нет) и не структуры входящего адаптера (не знают про формат передачи). Подробно — в статье про сторону запросов. - Результаты, которые не сводятся к одному значению. Обработчик команды вернул не только идентификатор, а «идентификатор плюс рассчитанную сумму плюс срок» — это запись в той же папке, а не доменный объект.
- Структуры между сценариями — то, что подразумевает фраза «передаются между use cases». И вот здесь стоит предупредить: сценарий, вызывающий другой сценарий, — обычно признак ошибки. Если обработчик команды зовёт другой обработчик команды, у вас либо два шага одного процесса (и тогда это сага или один сценарий), либо общее правило, которое надо вынести в домен. Настоящая нужда в структурах «между сценариями» возникает редко, и папка, полная ими, — сигнал, что сценарии выстроены в цепочку.
Чем структура входа команды отличается от этих структур. Команда (CreateOrderCommand) — это запрос на изменение с полями входа; она приходит снаружи и валидируется по формату в адаптере. Внутренние структуры — это результаты, они идут наружу. Путаница между ними приводит к тому, что команда обрастает полями «для ответа», а структура ответа — полями «для входа».
domain/port/in/ в дереве выше отсутствует, и это осознанно: раскладка на сайте использует типы команд и запросов как входные порты (обработчик находится по типу через диспетчер), поэтому отдельных интерфейсов входа нет. Если вы выбрали каноническую форму с интерфейсом на операцию, они лежат рядом с исходящими: domain/port/in/PlaceOrderUseCase.java. Разбор трёх вариантов и их цены — в разделе про входящий порт.
Три папки ядра связаны одной цепочкой вызовов, и только последняя стрелка выходит за границу core.
Доменные события: кто их публикует
В дереве события лежат в domain/<bc>/event/, и registerEvent вызывается прямо в агрегате. Что происходит дальше — вопрос, на который надо ответить, иначе событие останется объектом в памяти.
Агрегат только регистрирует. Метод агрегата добавляет событие в свой внутренний список и ничего больше не делает: ни публикации, ни отправки, ни записи. Это принципиально — агрегат не должен знать, кто и как доставит событие, и тем более не должен обращаться к инфраструктуре из метода правила.
public class Order {
private final List<DomainEvent> events = new ArrayList<>();
public void confirm(Instant now) {
if (status != NEW) throw new OrderAlreadyConfirmedException(id, status);
status = CONFIRMED;
events.add(new OrderConfirmedEvent(id, now)); // только регистрация
}
public List<DomainEvent> pullEvents() { // забирают один раз
List<DomainEvent> pulled = List.copyOf(events);
events.clear();
return pulled;
}
}
Забирает и отдаёт наружу — репозиторий или обработчик. Два рабочих варианта. Первый: репозиторий при сохранении забирает события агрегата и пишет их в таблицу исходящих сообщений — той же транзакцией, что и сам агрегат. Тогда обработчик вообще про события не думает, а атомарность «изменение плюс событие» получается по построению. Второй: обработчик явно забирает события и отдаёт их в порт исходящих сообщений — чуть больше кода, зато видно в сценарии.
Почему именно таблица исходящих, а не публикация в брокер сразу. Публикация из ядра невозможна (ядро не знает про брокер), а публикация из обработчика после фиксации транзакции даёт окно, в котором событие может пропасть: транзакция зафиксирована, процесс умер, событие не ушло. Запись в таблицу той же транзакцией это окно закрывает, а отправку делает отдельный процесс. Механика — в статье про синхронизацию через события.
Что попадает в событие. Только то, что есть в ядре: идентификаторы, доменные значения, время (полученное снаружи, а не now() внутри — см. раздел «Глубже»). Не попадают: структуры внешних форматов, сущности целиком «на всякий случай», ссылки на доменные объекты (событие должно быть самодостаточным снимком факта).
И чего в ядре быть не должно, хотя соблазн есть: обработчиков своих же событий, которые тут же вызывают другие сценарии. Событие, обработанное синхронно внутри того же процесса, — это просто вызов метода, только спрятанный; если развязка не нужна, зовите напрямую.
Зависимости между контекстами внутри одного ядра
Дерево показывает несколько контекстов (orders, customers, payments) в одном ядре и не говорит, можно ли orders импортировать customers. Ответ: нет, и это то же правило границ, что между сервисами — просто проверяется дешевле.
Почему нельзя. Если Order держит ссылку на объект Customer, то: изменение правил клиентов задевает заказы; загрузка заказа тянет клиента; границы транзакций размываются (одна команда меняет два агрегата из разных контекстов); а через год контексты нельзя разделить, потому что они склеены типами.
Как тогда. Три приёма, по частоте применения:
- Идентификатор вместо объекта. В заказе лежит
CustomerId, а неCustomer. Это девяносто процентов случаев, и больше ничего не нужно. - Свой маленький тип с нужными полями. Заказу нужна не вся карточка клиента, а «уровень скидки» — заводим в контексте заказов своё значение (
CustomerTier), которое заполняется на входе в сценарий. Это не дублирование, а перевод чужого понятия в своё, ровно как между сервисами. - Через порт, если данные нужны в момент решения. Обработчик заказа получает нужный факт через исходящий порт (
CustomerTierPort), реализация которого читает другой контекст или чужой сервис. Ядро при этом не знает, где живут клиенты.
Что остаётся общим и это нормально: базовые значения (денежная сумма, период, идентификатор как тип) — их место в общем пакете domain/shared/, и в нём только значения без правил предметной области. Как только туда попадает объект с бизнес-правилами, общий пакет становится тем самым складом, который склеивает контексты.
Чем проверить. Тем же тестом архитектуры: «domain.orders не импортирует domain.customers, и наоборот; оба могут импортировать domain.shared». Одно правило, которое сохраняет возможность однажды разрезать сервис по контекстам без переписывания.
Что в core/ нельзя
Core зависит только от JDK, Lombok, jakarta.validation API и своих domain-библиотек. Почему эти три можно, а Jackson нет, — вопрос законный, и ответ не «так сложилось».
Lombok в собранный артефакт не попадает вовсе: он работает во время компиляции и генерирует обычные методы, так что в скомпилированном классе от него не остаётся ни импорта, ни ссылки. jakarta.validation — это чистые аннотации спецификации, без реализации: они ничего не делают сами, их читает валидатор, который живёт снаружи. Jackson же не аннотации, а библиотека сериализации: приняв её, core начинает зависеть от формата ответа HTTP и ломается, когда формат меняется.
Отсюда и граница применения jakarta.validation внутри core. Аннотации вроде @NotNull и @Size ставят на команды и application-DTO — это описание входных данных, и проверяет их валидатор на границе. На агрегате их быть не должно: там действует правило «ни одной инфраструктурной аннотации», а инварианты проверяются кодом внутри доменных методов, а не внешним валидатором.
Всё остальное — вне:
- Spring (
org.springframework.*) — фреймворк. Core не знает о контейнере. - jOOQ — детали persistence. Core работает с доменными объектами, а не со сгенерированными POJO из схемы БД.
- Jackson — JSON-сериализация. Это деталь HTTP-адаптера.
- OkHttp / Retrofit — HTTP-клиенты. Если нужно обратиться к внешнему сервису, core описывает port-интерфейс, адаптер его реализует через HTTP.
- Kafka-клиент — детали транспорта. Core публикует domain event, адаптер решает, как его доставить.
Если в core/ появился такой import — файл лежит не там, где должен, или нарушена граница слоёв.
Rich domain против анемичной модели
Это ключевое решение, от которого зависит, принесёт ли hexagonal пользу или останется просто набором папок.
Анемичная модель — когда доменный объект это просто контейнер данных с геттерами и сеттерами, а вся логика сосредоточена в *Service-классах снаружи:
// Анемичный Order — только данные
public class Order {
private OrderStatus status;
private List<OrderItem> items;
// getters, setters...
}
// Вся логика — снаружи, в сервисе
@Service
public class OrderService {
public void confirm(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
if (order.getItems().isEmpty()) { ... }
if (order.getStatus() != OrderStatus.NEW) { ... }
order.setStatus(OrderStatus.CONFIRMED);
orderRepository.save(order);
}
}
Проблема в том, что логику подтверждения заказа придётся повторить в нескольких местах: в REST-контроллере, в Kafka-листенере, в административном CLI. Рано или поздно одна копия отстанет от остальных. Написать unit-тест на Order.confirm() невозможно — у Order нет логики. Все тесты тянут за собой Spring и базу данных.
Rich domain — когда бизнес-логика живёт внутри агрегата:
public class Order extends AggregateRoot<OrderId> {
private OrderStatus status;
private List<OrderItem> items;
private Money total;
public void confirm() {
if (items.isEmpty()) {
throw new EmptyOrderException(this.id);
}
if (status != OrderStatus.NEW) {
throw new IllegalOrderStatusException(status, OrderStatus.NEW);
}
if (total.compareTo(Money.ZERO) <= 0) {
throw new InvalidOrderTotalException(total);
}
this.status = OrderStatus.CONFIRMED;
registerEvent(new OrderConfirmedEvent(this.id, total));
}
public void cancel(CancellationReason reason) { /* ... */ }
}
AggregateRoot<OrderId> — база из доменной библиотеки: она держит идентификатор id и метод registerEvent, который складывает события агрегата в список до момента сохранения. Без неё в классе не было бы ни id, ни registerEvent.
И ещё одна деталь контракта: Money здесь реализует Comparable<Money> — иначе compareTo вызвать не получится. Сравнение сумм — законная часть value object'а денег, рядом со сложением и константой Money.ZERO.
Handler при этом остаётся простым:
public Order handle(ConfirmOrderCommand cmd) {
Order order = orderRepository.findById(cmd.id()).orElseThrow();
order.confirm(); // вся логика внутри агрегата
orderRepository.save(order);
return order;
}
Что это даёт: логика подтверждения написана один раз и лежит в одном месте. Тест на Order.confirm() — простой unit-тест без Spring. Изменение правила подтверждения — правка в одном классе, а не поиск по всем сервисам.
Почему сгенерированные POJO нельзя тащить в core
jOOQ генерирует классы по схеме базы данных: OrdersRecord, OrdersPojo. Это удобные объекты для работы с persistence-слоем, но они привязаны к конкретной схеме БД.
Если port-интерфейс репозитория возвращает OrdersPojo, core оказывается привязан к структуре таблицы. Переименовалась колонка — сломался core. Переехали на другую базу — сломался core.
// Неправильно — POJO из схемы БД попал в core
public interface OrderRepository {
Optional<OrdersPojo> findById(OrderId id);
}
// Правильно — port работает с доменным объектом
public interface OrderRepository {
Optional<Order> findById(OrderId id);
}
Маппинг между OrdersRecord и Order живёт в persistence-адаптере и не виден core.
Почему HTTP-DTO нельзя тащить в core
Аналогичная история с REST-контрактом. CreateOrderRequest — это форма HTTP-API, она описывает, что пришло в запросе. Она меняется под требования клиентов, может быть версионирована.
// Неправильно — HTTP-DTO в core
public class CreateOrderCommand {
private CreateOrderRequest request; // REST-DTO попал в core
}
// Правильно — command содержит доменные типы
public record CreateOrderCommand(
CustomerId customerId,
List<OrderItemRequest> items,
Money total
) implements UseCaseCommand<Order> {}
Маппинг CreateOrderRequest → CreateOrderCommand делает in-адаптер (REST-контроллер) и не затрагивает core.
Spring-аннотации в core/
По умолчанию core/ не зависит от Spring, поэтому @Component и @Service там не нужны и не появляются. Handler'ы и другие core-объекты регистрируются в контейнере явно через @Bean-фабрики в bootstrap/:
// bootstrap/.../CoreBeansConfig.java
@Configuration
public class CoreBeansConfig {
@Bean
public CreateOrderCommandHandler createOrderCommandHandler(
OrderRepository orderRepo, PaymentPort paymentPort) {
return new CreateOrderCommandHandler(orderRepo, paymentPort);
}
}
На небольшом сервисе это работает хорошо. Когда use cases становится много (30–50 handler'ов), объём фабрик растёт. В этом случае используют usecase-pattern-starter с маркером @CoreComponent — он сканирует core-классы и регистрирует их автоматически. Зависимость при этом всё же появляется: аннотацию @CoreComponent надо откуда-то импортировать, и core начинает зависеть от модуля с аннотациями библиотеки. Важно, что это не Spring: модуль с аннотациями не тянет за собой контейнер, и core по-прежнему собирается и тестируется без него.
Сколько именно — полезно представлять до того, как решать. Одна фабрика на обработчик — это 4–6 строк: подпись метода с зависимостями, вызов конструктора, закрывающая скобка. Отсюда:
| Обработчиков | Строк конфигурации | Файлов конфигурации |
|---|---|---|
| 5 | 25–30 | 1 |
| 10 | 50–60 | 1–2 |
| 30 | 150–180 | 3–4 (по смыслу: заказы, клиенты, платежи) |
| 50 | 250–300 | 5–6 |
До десяти обработчиков это один файл, который читается целиком и служит описью содержимого ядра — то есть скорее польза, чем цена. На тридцати файлы делят по контекстам, и главное неудобство не в объёме, а в том, что добавление обработчика требует правки в двух местах, и про вторую забывают: приложение падает при старте с «нет бина» — быстро, понятно, но раздражает.
Промежуточные решения, если тридцать уже есть, а тащить библиотеку не хочется: фабрика на контекст (один метод создаёт несколько связанных обработчиков), сборка через общий конструктор зависимостей (один объект со всеми портами, передаётся во все обработчики — экономит строки, но прячет реальные зависимости) и тест, который проверяет, что каждый обработчик зарегистрирован (находит забытую фабрику в сборке, а не при старте в проде). Последнее стоит завести в любом случае — это пять строк.
Чем это отличается от пакета domain в обычном сервисе
Самый честный вопрос к этой статье: всё описанное можно сделать пакетом внутри одного модуля — так зачем отдельный модуль сборки? Мотивация «нельзя протестировать без фреймворка» действительно не требует модуля: пакет с чистыми классами тестируется так же быстро.
Разница ровно в трёх вещах, и других нет:
1. Граница проверяется сборкой, а не договорённостью. В одном модуле любой класс может импортировать любой другой; ничто не мешает написать в доменном классе импорт записи базы — код соберётся. В отдельном модуле у ядра нет этой зависимости в настройках сборки, и такой импорт не компилируется. Разница между «нельзя по правилам» и «не собирается» проявляется не сразу, а через полгода и пять разработчиков.
2. Ядро собирается и тестируется отдельно. Прогон тестов ядра не требует собирать адаптеры и не ждёт их зависимостей: на большом проекте это разница между секундами и минутами, а при десятках прогонов в день — заметная часть рабочего дня. В одном модуле изменение в контроллере пересобирает и перепроверяет всё.
3. Зависимости адаптеров не попадают в ядро даже случайно. В одном модуле все библиотеки лежат в одном пути сборки: автодополнение предложит класс библиотеки хранения в доменном классе, и человек его выберет, не задумываясь. В отдельном модуле этого класса там просто нет — и это, пожалуй, самый недооценённый эффект: границу держит не дисциплина, а отсутствие возможности.
И то, чего модуль НЕ даёт, хотя иногда ожидают: он не делает код лучше сам по себе, не заменяет тест архитектуры внутри ядра (внутренние правила — что домен не зависит от сценариев — модуль не проверяет) и не защищает от анемичной модели.
Практический вывод. Пакет достаточен, когда: одна команда, тест архитектуры стоит и краснеет, время сборки устраивает. Модуль нужен, когда: несколько команд правят один сервис, время прогона тестов мешает, или у адаптеров конфликтующие зависимости. Это ровно та развилка между второй и третьей раскладкой, которую разбирает статья о выборе; и честный ответ в том, что для большинства сервисов пакета достаточно, а модуль — инструмент под названные причины.
Глубже: время и случайность в ядре: Clock и генератор идентификаторов как портырасширенное
В списке того, что в core нельзя, нет двух вещей, которые проходят любой импорт-фильтр, потому что живут в JDK: Instant.now() и UUID.randomUUID(). В командной статье CQRS Instant.now() вызывается прямо из агрегата, а в банке задач это разбирается как ошибка, и стоит объяснить, почему.
Правило, которое нарушается: ядро детерминировано. Одна и та же команда над одним и тем же состоянием даёт один и тот же результат; так его можно тестировать без подмен и воспроизводить любой сценарий. Instant.now() внутри агрегата делает результат зависимым от момента запуска: тест «заказ, оформленный до полуночи, попадает в отчёт за день» либо проходит по случайности, либо падает в 23:59; идентификатор, сгенерированный внутри, делает невозможным сравнить события с ожидаемыми.
Решение это два port'а. Время приходит снаружи: обработчик получает Clock (стандартный java.time.Clock, это интерфейс) и передаёт Instant now = clock.instant() в метод агрегата параметром, либо агрегат получает Clock в конструкторе; в проде это Clock.systemUTC() из bootstrap, в тестах Clock.fixed(...). Идентификаторы выдаёт port IdGenerator с методом nextOrderId(), реализация в bootstrap использует UUID седьмой версии, тест подставляет последовательность. Оба port'а не нарушают чистоту ядра: java.time.Clock из JDK, IdGenerator объявлен в core.
Проверка одна строка ArchUnit: методы core не вызывают Instant.now, LocalDateTime.now, UUID.randomUUID, Math.random, System.currentTimeMillis. Она ловит и то, что придёт с библиотекой, которая вызывает их внутри. Побочный выигрыш: время в командах становится явным полем, и «заказ оформлен в момент X» это данные команды, которые можно повторить при переигрывании событий, а не то, что случилось при выполнении.
Коротко
- Признак, по которому файл кладут в core, а не в адаптер: он говорит о правилах предметной области и компилируется без библиотек инфраструктуры. Отсюда и список разрешённого: JDK, Lombok,
jakarta.validationAPI, domain-библиотеки; Spring, jOOQ, Jackson, HTTP-клиенты и Kafka остаются снаружи. - Порт объявляет ядро, а реализует адаптер: смена хранилища или брокера правит адаптер и не трогает ядро. Бизнес-логика живёт внутри агрегата (
order.confirm()), а не в*Service-классах снаружи. - Сгенерированные POJO (jOOQ) и HTTP-DTO (
CreateOrderRequest) не должны попадать в core — они привязывают его к деталям инфраструктуры. - Handler остаётся простым: найти агрегат, вызвать метод, сохранить.
- Ядро детерминировано:
Instant.now()иUUID.randomUUID()в нём запрещены; время приходит черезClock, идентификаторы через portIdGenerator, тесты подставляют фиксированные, ArchUnit ловит вызовы. - Во внутренних структурах ядра живут ответы чтения и составные результаты команд; сценарий, вызывающий сценарий, — признак ошибки, а входные порты есть только в канонической раскладке.
- Агрегат события только регистрирует, наружу их отдаёт репозиторий или обработчик — в таблицу исходящих той же транзакцией; в событии только доменные значения и время, полученное снаружи.
- Контексты внутри ядра друг друга не импортируют: идентификатор вместо объекта, свой тип с нужными полями или порт; общими остаются только значения без правил, и это проверяется тестом.
- Фабрика на обработчик — 4–6 строк: до десяти это опись содержимого ядра, на тридцати файлы делят по контекстам и заводят тест, проверяющий, что все обработчики зарегистрированы.
- Отдельный модуль даёт ровно три вещи: границу, проверяемую сборкой, отдельный быстрый прогон тестов ядра и отсутствие библиотек адаптеров в области видимости; для большинства сервисов достаточно пакета с тестом архитектуры.
Что почитать дальше
- Порты и адаптеры — как port-интерфейсы связывают core с инфраструктурой.
- Use Case Pattern — как устроены Command, Query и Handler.
- DDD: агрегаты и value objects — основы доменного моделирования.