Опирается на правила: R-HEX-CORE-1R-HEX-CORE-4 и R-HEX-CORE-X1R-HEX-CORE-X5 из Hexagonal Style Guide → раздел 3. Core слой.

Важно знать

  • core/ зависит только от JDK, Lombok, ddd-building-blocks, usecase-pattern, hexagonal-architecture, jakarta.validation API.
  • Никаких 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 GuideR-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-типы

Куда дальше