← Back to the section

A service grows, and its business logic quietly fuses with infrastructure — the code that talks to the database, sends messages to a broker, and parses HTTP. At some point a simple business operation can no longer be tested without a running database and Spring, and switching the storage technology breaks code that was supposed to be about business. Hexagonal Architecture answers this as follows: all the business logic is gathered into a single layer — the core, which knows nothing about infrastructure. Let's look at what goes into the core, what should never end up there, and why this boundary matters so much.

Why you need this boundary at all

In a typical Spring application the boundary between business logic and infrastructure is blurred. OrderService receives a jOOQ repository, calls orderRepository.fetchOne(...), does the JSON mapping itself, and sends an event to Kafka. Everything is in one place.

When it's time to write a test, it turns out you can't test the order-confirmation logic without spinning up Spring, the database, and Kafka. When the database schema changes, logic breaks right inside OrderService. When you need to swap Kafka for RabbitMQ, you have to change code that supposedly was about business.

Hexagonal Architecture solves this with one rule: the core knows nothing about infrastructure. It doesn't know that data is stored in PostgreSQL, that the API is REST, that messages travel through Kafka. The core only says "I need a repository that can save an order" — and describes that as an interface. Exactly how it's implemented is the adapter's concern.

What goes into core/

A typical structure looks like this:

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/

Let's go through each part:

domain/<bc>/ — domain objects grouped by bounded context (orders, customers, payments). Inside each context: aggregates, entities, value objects, events, exceptions.

domain/port/out/ — outbound port interfaces. This is a description of what the core needs from the outside world: "be able to find an order", "be able to accept a payment", "be able to send a notification". The implementations of these interfaces live in the adapters — persistence, http-client, and so on.

usecase/ — Command/Query + Handler pairs. Commands change the state of an aggregate; queries return data for reading.

dto/ — internal application DTOs (records) that are passed between use cases. Not to be confused with HTTP DTOs — those live in the adapter.

What is not allowed in core/

The core depends only on the JDK, Lombok, the jakarta.validation API, and its own domain libraries. Everything else stays outside:

  • Spring (org.springframework.*) — the framework. The core knows nothing about the container.
  • jOOQ — persistence details. The core works with domain objects, not with generated POJOs derived from the database schema.
  • Jackson — JSON serialization. That's a detail of the HTTP adapter.
  • OkHttp / Retrofit — HTTP clients. If it needs to call an external service, the core describes a port interface and the adapter implements it over HTTP.
  • The Kafka client — transport details. The core publishes a domain event; the adapter decides how to deliver it.

If such an import appears in core/, the file is in the wrong place, or the layer boundary has been broken.

Rich domain versus the anemic model

This is the key decision that determines whether hexagonal brings any benefit or just remains a set of folders.

The anemic model — when a domain object is just a data container with getters and setters, and all the logic is concentrated in *Service classes on the outside:

// Anemic Order — data only
public class Order {
    private OrderStatus status;
    private List<OrderItem> items;
    // getters, setters...
}

// All the logic — outside, in the service
@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);
    }
}

The problem is that the confirmation logic will have to be repeated in several places: in the REST controller, in the Kafka listener, in the admin CLI. Sooner or later one copy falls behind the others. Writing a unit test for Order.confirm() is impossible — Order has no logic. Every test drags in Spring and the database.

Rich domain — when the business logic lives inside the aggregate:

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) { /* ... */ }
}

The handler then stays simple:

public Order handle(ConfirmOrderCommand cmd) {
    Order order = orderRepository.findById(cmd.id()).orElseThrow();
    order.confirm();           // all the logic is inside the aggregate
    orderRepository.save(order);
    return order;
}

What this gives you: the confirmation logic is written once and lives in one place. A test for Order.confirm() is a simple unit test without Spring. Changing a confirmation rule is an edit in a single class, not a hunt across every service.

Why generated POJOs must not be dragged into the core

jOOQ generates classes from the database schema: OrdersRecord, OrdersPojo. These are convenient objects for working with the persistence layer, but they are tied to a specific database schema.

If a repository's port interface returns OrdersPojo, the core becomes bound to the table structure. A column gets renamed — the core breaks. You migrate to a different database — the core breaks.

// Wrong — a POJO from the DB schema leaked into the core
public interface OrderRepository {
    Optional<OrdersPojo> findById(Long id);
}

// Right — the port works with a domain object
public interface OrderRepository {
    Optional<Order> findById(Long id);
}

The mapping between OrdersRecord and Order lives in the persistence adapter and is invisible to the core.

Why HTTP DTOs must not be dragged into the core

The same story with the REST contract. CreateOrderRequest is the shape of the HTTP API; it describes what arrived in the request. It changes to meet client requirements and can be versioned.

// Wrong — an HTTP DTO in the core
public class CreateOrderCommand {
    private CreateOrderRequest request;  // a REST DTO leaked into the core
}

// Right — the command holds domain types
public record CreateOrderCommand(
    CustomerId customerId,
    List<OrderItemRequest> items,
    Money total
) implements UseCaseCommand<Order> {}

The mapping CreateOrderRequestCreateOrderCommand is done by the in-adapter (the REST controller) and does not touch the core.

Spring annotations in core/

By default core/ doesn't depend on Spring, so @Component and @Service aren't needed there and don't appear. Handlers and other core objects are registered in the container explicitly through @Bean factories in bootstrap/:

// bootstrap/.../CoreBeansConfig.java
@Configuration
public class CoreBeansConfig {
    @Bean
    public CreateOrderCommandHandler createOrderCommandHandler(
            OrderRepository orderRepo, PaymentPort paymentPort) {
        return new CreateOrderCommandHandler(orderRepo, paymentPort);
    }
}

On a small service this works well. When the use cases become numerous (30–50 handlers), the volume of factories grows. In that case you use the usecase-pattern-starter with the @CoreComponent marker — it scans the core classes and registers them automatically, without adding a dependency on Spring to core/ itself.

In short

  • The core is the heart of the service. Everything that doesn't depend on infrastructure: aggregates, port interfaces, use cases, application DTOs.
  • The core depends only on the JDK, Lombok, the jakarta.validation API, and domain libraries. Spring, jOOQ, Jackson, HTTP clients, Kafka — all outside.
  • Port interfaces in domain/port/out/ describe what the core needs from the outside world. The implementations live in the adapters.
  • Business logic lives inside the aggregate (order.confirm()), not in *Service classes on the outside.
  • Generated POJOs (jOOQ) and HTTP DTOs (CreateOrderRequest) must not end up in the core — they bind it to infrastructure details.
  • The handler stays simple: find the aggregate, call a method, save.

Further reading