Сервис растёт, и бизнес-логика незаметно срастается с инфраструктурой — кодом, который ходит в базу, шлёт сообщения в брокер и разбирает 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 и т.д.
usecase/ — пары Command/Query + Handler. Команды меняют состояние агрегата, query возвращают данные для чтения.
dto/ — внутренние application-DTO (records), которые передаются между use cases. Не путать с HTTP-DTO — те живут в адаптере.
Что в core/ нельзя
Core зависит только от JDK, Lombok, jakarta.validation API и своих domain-библиотек. Всё остальное — вне:
- 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.DRAFT) { ... }
order.setStatus(OrderStatus.CONFIRMED);
orderRepository.save(order);
}
}
Проблема в том, что логику подтверждения заказа придётся повторить в нескольких местах: в REST-контроллере, в Kafka-листенере, в административном CLI. Рано или поздно одна копия отстанет от остальных. Написать unit-тест на Order.confirm() невозможно — у Order нет логики. Все тесты тянут за собой Spring и базу данных.
Rich domain — когда бизнес-логика живёт внутри агрегата:
public class Order {
private OrderStatus status;
private List<OrderItem> items;
private Money total;
public void confirm() {
if (items.isEmpty()) {
throw new EmptyOrderException(this.id);
}
if (status != OrderStatus.DRAFT) {
throw new IllegalOrderStatusException(status, OrderStatus.DRAFT);
}
if (total.compareTo(Money.ZERO) <= 0) {
throw new InvalidOrderTotalException(total);
}
this.status = OrderStatus.CONFIRMED;
registerEvent(new OrderConfirmedEvent(id, total));
}
public void cancel(CancellationReason reason) { /* ... */ }
}
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(Long id);
}
// Правильно — port работает с доменным объектом
public interface OrderRepository {
Optional<Order> findById(Long 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-классы и регистрирует их автоматически, не добавляя зависимости от Spring в сам core/.
Коротко
- Core — сердце сервиса. Всё, что не зависит от инфраструктуры: агрегаты, port-интерфейсы, use cases, application-DTO.
- Core зависит только от JDK, Lombok,
jakarta.validationAPI и domain-библиотек. Spring, jOOQ, Jackson, HTTP-клиенты, Kafka — снаружи. - Port-интерфейсы в
domain/port/out/описывают, что core нужно от внешнего мира. Реализации — в адаптерах. - Бизнес-логика живёт внутри агрегата (
order.confirm()), а не в*Service-классах снаружи. - Сгенерированные POJO (jOOQ) и HTTP-DTO (
CreateOrderRequest) не должны попадать в core — они привязывают его к деталям инфраструктуры. - Handler остаётся простым: найти агрегат, вызвать метод, сохранить.
Что почитать дальше
- Порты и адаптеры — как port-интерфейсы связывают core с инфраструктурой.
- Use Case Pattern — как устроены Command, Query и Handler.
- DDD: агрегаты и value objects — основы доменного моделирования.