Опирается на правила:
R-HEX-CORE-1…R-HEX-CORE-4иR-HEX-CORE-X1…R-HEX-CORE-X5из Hexagonal Style Guide → раздел 3. Core слой.
Важно знать
core/зависит только от JDK, Lombok,ddd-building-blocks,usecase-pattern,hexagonal-architecture,jakarta.validationAPI.- Никаких Spring, JOOQ, Jackson, OkHttp, Retrofit, Kafka-clients — это infrastructure.
- Содержит: domain (агрегаты, VO, события, port-интерфейсы) + usecase (Command/Query + Handler) + dto + service.
- Rich domain: бизнес-логика внутри entity/aggregate (
order.confirm()), не в*Service-классах. Anemic domain — антипаттерн.- Generated POJO (jOOQ) в
core/как доменный тип — запрещён. POJO — деталь persistence.- HTTP-DTO (
OrderJson,CreateOrderRequest) вcore/— запрещены. REST-DTO — деталь in-adapter.@Component/@Serviceна классах core/ разрешены только через стартерusecase-pattern-starter; иначе — голые POJO + явные@Beanвbootstrap/.
core/ — это сердце сервиса. Здесь живут агрегаты, бизнес-правила, инварианты, события. Это то, что не зависит ни от какой инфраструктуры и могло бы работать без Spring, без PostgreSQL, без HTTP. На практике мы запускаем это в Spring Boot, но сам core этого не знает — он принимает зависимости через port-интерфейсы, которые реализуют адаптеры. Раскрытие правил R-HEX-CORE-* ниже.
Что в core/ можно
R-HEX-CORE-1: точный список разрешённых зависимостей.
// core/build.gradle.kts
dependencies {
implementation("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")
implementation("ru.vikulinva:ddd-building-blocks:1.x") // Entity, Aggregate, VO маркеры
implementation("ru.vikulinva:usecase-pattern:1.x") // UseCase / Handler / Dispatcher
implementation("ru.vikulinva:hexagonal-architecture:1.x") // @CoreComponent, @Port маркеры
implementation("jakarta.validation:jakarta.validation-api") // BeanValidation API без impl
// Hibernate Validator (implementation) — это уже в bootstrap/
}
Что запрещено в core:
org.springframework.*— Spring как фреймворк.org.jooq.*— JOOQ как persistence-технология.com.fasterxml.jackson.*— JSON-сериализация.okhttp3.*,retrofit2.*— HTTP-клиенты.org.apache.kafka.*,org.springframework.kafka.*— Kafka.- Любая другая infrastructure-библиотека.
Если в core/ мелькнул такой import — это либо забыли правильно расположить файл (он должен быть в адаптере), либо нарушение архитектуры. ArchUnit-тест (R-HEX-TEST-1) ловит это автоматически.
Структура core/
R-HEX-CORE-2: типичная раскладка.
core/src/main/java/<pkg>/
├── domain/ # DDD-tactical
│ ├── <bc>/ # Bounded Context (orders, customers, payments)
│ │ ├── aggregate/Order.java # Aggregate Root
│ │ ├── entity/OrderItem.java # Entity
│ │ ├── valueobject/Money.java # Value Object
│ │ ├── event/OrderConfirmedEvent.java
│ │ └── exception/OrderNotFoundException.java
│ └── port/out/ # Outbound port-интерфейсы
│ ├── OrderRepository.java
│ ├── PaymentPort.java
│ └── NotificationPort.java
├── usecase/ # Use Case Pattern
│ ├── command/CreateOrderCommand.java + CreateOrderCommandHandler.java
│ └── query/GetOrdersQuery.java + GetOrdersQueryHandler.java
├── dto/ # внутренние application DTO (records)
└── service/ # shared business services (по необходимости)
Обрати внимание:
domain/<bc>/— domain группируется по bounded context'у. Один сервис может иметь 1–3 BC (редко больше). В каждом — свои агрегаты, VO, события.port/out/— outbound интерфейсы. Это «что core нужно от внешнего мира»: репозитории, клиенты внешних систем, event publishers.usecase/— Command/Query + Handler пары. Команды меняют состояние агрегата, query возвращают read-проекции.service/— shared domain-логика, которая не помещается в один агрегат. Используется редко; чаще — domain-метод на агрегате или domain-event.
Rich domain — методы внутри агрегата
R-HEX-CORE-4: бизнес-логика живёт в entity/aggregate, не в *Service-классах.
// ХОРОШО
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 — простой
@UseCaseHandler
public Order handle(ConfirmOrderCommand cmd) {
Order order = orderRepository.findById(cmd.id(), SelectMode.FOR_UPDATE).orElseThrow();
order.confirm(); // ← вся логика тут
orderRepository.save(order);
return order;
}
// ПЛОХО — anemic
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);
}
}
Что не так с anemic:
- Инварианты разъезжаются.
confirm()логика повторяется вOrderController, в Kafka-listener'е, в admin-CLI — рано или поздно одна из копий отстанет. - Тестируется только через service-слой. Никаких unit-тестов на
Order.confirm()— он же без логики. Все тесты —@SpringBootTestс поднятой инфраструктурой. - Domain становится свалкой геттеров/сеттеров. Чтение кода превращается в «найди все места, где меняется статус, чтобы понять lifecycle».
Rich domain в DDD — это см. DDD Tactical Style Guide — R-AGG-* про агрегаты и R-VO-* про value objects.
@Component в core/ — только через стартер
R-HEX-CORE-3: на классах core/ Spring-аннотации (@Component, @Service, @Repository) — разрешены только если используется наш стартер usecase-pattern-starter, который автоматически их регистрирует.
Что это значит на практике:
core/-build НЕ зависит от Spring (в gradle нетspring-context).- На классах могут быть
@Component-аннотации — они компилируются как обычные Java-аннотации, потому что@Componentлежит вspring-context.jar, а на компиляции его НЕТ в classpath core. На самом деле —core/использует наш@CoreComponent-маркер (изhexagonal-architecture-библиотеки), который не зависит от Spring. - Стартер в
bootstrap/сканирует@CoreComponent-помеченные классы и регистрирует их вApplicationContext.
Альтернатива — голые POJO в core/ + явные @Bean-фабрики в bootstrap/:
// bootstrap/.../CoreBeansConfig.java
@Configuration
public class CoreBeansConfig {
@Bean
public CreateOrderCommandHandler createOrderCommandHandler(
OrderRepository orderRepo, PaymentPort paymentPort) {
return new CreateOrderCommandHandler(orderRepo, paymentPort);
}
}
Это работает, но boilerplate растёт линейно с числом use case'ов. На реальном сервисе из 30–50 handler'ов это сотни строк фабрик. Поэтому в типичном UCP-сервисе подключают usecase-pattern-starter и используют @CoreComponent-маркер.
См. библиотеки — usecase-pattern, hexagonal-architecture.
Что запрещено
R-HEX-CORE-X1: Spring-импорт в core/. Compile-error из-за gradle-настройки, плюс ArchUnit-тест на тот случай, если кто-то добавит spring-dependency в core/build.gradle.
R-HEX-CORE-X2: JOOQ-импорт в core/. JOOQ — деталь persistence, не доменное знание. core/ работает с domain entity (Order), а не с jOOQ-record (OrdersRecord). Mapping POJO ↔ Domain — в persistence/.../<X>DomainRecordMapper.java (см. Mapping в jOOQ).
R-HEX-CORE-X3: Anemic domain model — см. выше. Если core/ это набор POJO + Spring-сервисов, hexagonal не дал ничего, кроме ceremony.
R-HEX-CORE-X4: Generated POJO (OrdersPojo, TicketsPojo) в core/ как доменный тип.
// ПЛОХО
public interface OrderRepository {
Optional<OrdersPojo> findById(Long id); // ← POJO протёк в core
}
OrdersPojo — это generated класс из jOOQ-codegen, привязан к схеме БД. Если завтра колонка переименовалась, ломается весь core. Domain должен жить независимо от схемы.
// ХОРОШО
public interface OrderRepository {
Optional<Order> findById(Long id, SelectMode mode); // ← domain entity
}
R-HEX-CORE-X5: HTTP-DTO (OrderJson, CreateOrderRequest) в core/.
// ПЛОХО
public class CreateOrderCommand {
private CreateOrderRequest request; // ← REST-DTO в core
}
REST-DTO — это форма HTTP-API, она меняется по требованиям клиентов. Если она протекает в core, изменение API ломает domain. Маппинг REST-DTO ↔ command/domain — в *-in-adapter.
// ХОРОШО
public record CreateOrderCommand(
CustomerId customerId,
List<OrderItemRequest> items,
Money total
) implements UseCaseCommand<Order> {} // ← domain-типы
Куда дальше
- Hexagonal Style Guide → раздел 3. Core слой — нормативные формулировки.
- DDD Tactical Style Guide — что такое агрегат, entity, value object, domain event.
- Use Case Pattern — про UseCase + Handler + Dispatcher.
- Ports — про port-интерфейсы в
core/<bc>/port/out/. - Mapping record ↔ domain в jOOQ — где живёт mapping POJO ↔ Domain.