“Does the controller implement the incoming port?” “Where do I put the repository interface?” When you start with hexagonal architecture, or when you come from a layered application, the direction of the arrows is easy to get wrong.

This post explains the ideas one at a time, with one example: create an order with Spring Boot. At each step, one question guides you: here, who calls? who implements? The full code is in the public repository hexagonal-architecture-demo (tag v1). For a more real case, with Kafka and several use cases, see Hexagonal architecture with Spring Boot: a simple example.

The problem

In a layered application, each layer calls the layer below it: the controller calls the service, the service calls the repository, and the repository talks to the database. The dependencies go down. So the business service depends on JPA: to test it, you need a database, or a lot of mocks.

Hexagonal architecture turns some of these arrows around. In the center is the core: the domain and the use cases, in plain Java. Around it are the adapters: REST, JPA, Kafka, and so on. All dependencies point to the core. The core knows no adapter.

On the left, a layered application: the controller depends on the service, the service depends on the JPA repository, the repository depends on the database; the arrows go down, and the business code depends on JPA. On the right, the hexagonal architecture: in the center, the core (domain and use cases, in plain Java); the REST adapter and the JPA adapter depend on the core; the arrows point to the center.LayeredOrderControllerwebOrderServicebusinessOrderRepositoryJPAdatabaseThe arrows go down: the business code depends on JPA.HexagonalCoredomain +use casesplain JavaRESTadapterJPAadapterdatabaseThe arrows point to the core.

The name comes from Alistair Cockburn (2005). He also calls it “Ports and Adapters”. Its goal: “Allow an application to equally be driven by users, programs, automated test or batch scripts, and to be developed and tested in isolation from its eventual run-time devices and databases.” The hexagon shape has no special meaning: “The hexagon is not a hexagon because the number six is important”.

Here: in layers, each layer calls the one below and depends on it. In hexagonal architecture, the adapters depend on the core, never the other way.

The big picture

Here is the whole example in one diagram. There are two kinds of arrows: full line = “calls”, dotted line = “implements”.

Overview of the example. Incoming side: OrderController calls the incoming port CreateOrderUseCase, and CreateOrderService implements it. In the center: CreateOrderService calls Order, the domain. Outgoing side: CreateOrderService calls the outgoing port OrderRepository, and OrderPersistenceAdapter implements it, then writes to the H2 database. Full line: calls; dotted line: implements.The core (plain Java)CreateOrderServiceuse caseCreateOrderUseCaseincoming portOrderRepositoryoutgoing portOrderdomainimplementscallscallsHTTP requestOrderControllerincoming adaptercallsOrderPersistenceAdapteroutgoing adapterimplementsH2 databasecallsimplements

It all fits in two rules:

  • incoming side: an adapter (here the REST controller) calls an incoming port; the core implements it;
  • outgoing side: the core calls an outgoing port; an adapter (here JPA) implements it.

A port is an interface that belongs to the core. Cockburn defines it like this: “A port identifies a purposeful conversation.” An adapter translates between this port and a technology. Cockburn also talks about “driving adapters and driven adapters”: the adapters that drive the application (incoming), and the ones the application drives (outgoing).

In the repository, each part has its own package:

PackageContentKnows Spring?
domainOrder, OrderLine, InvalidOrderExceptionno
application.port.inCreateOrderUseCase, CreateOrderCommandno
application.port.outOrderRepositoryno
applicationCreateOrderServiceno
adapter.in.webthe REST controller and its DTOsyes
adapter.out.persistencethe JPA entity, the adapter, the mapperyes
configthe Spring wiringyes

Here: the incoming adapter calls, the core implements. The core calls, the outgoing adapter implements.

The domain

The domain holds the business rules, and nothing else. In the example, the rule is simple: an order has at least one line, each quantity is between 1 and 99, and the domain computes the total.

public final class Order {

    private final UUID id;
    private final List<OrderLine> lines;

    public Order(UUID id, List<OrderLine> lines) {
        if (id == null) {
            throw new InvalidOrderException("an order needs an id");
        }
        if (lines == null || lines.isEmpty()) {
            throw new InvalidOrderException("an order needs at least one line");
        }
        this.id = id;
        this.lines = List.copyOf(lines);
    }

    public static Order create(List<OrderLine> lines) {
        return new Order(UUID.randomUUID(), lines);
    }

    public BigDecimal total() {
        return lines.stream()
                .map(OrderLine::amount)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
    // id(), lines()
}

OrderLine is a record that checks the quantity in its constructor. The rules are checked when the object is created, so an Order is always valid.

Why no JPA annotation here? Because the shape of the database is not a business rule. A renamed column must not change Order. Why no DTO? For the same reason: the JSON format of the API belongs to the REST adapter.

In this post, the domain knows nobody: not Spring, not the ports, not the application. This is a common choice, not a rule from Cockburn: his page talks about “the application” in the center, and does not split it into a domain and use cases. Other code bases put interfaces in the domain (see “Common mistakes”).

Here: the domain calls no port and implements no port. The use case calls it.

The ports and the use case

The incoming port says what the application offers to the outside world. It is an interface, in application.port.in:

public interface CreateOrderUseCase {

    Order create(CreateOrderCommand command);
}
public record CreateOrderCommand(List<OrderLine> lines) {
}

The command has no JSON and no HTTP in it. Another adapter (a Kafka listener, a batch job, a test) can use it in the same way.

The outgoing port says what the application needs. It is also an interface, in application.port.out:

public interface OrderRepository {

    void save(Order order);
}

The core writes this interface, with its own words: save, not persist or insertInto. It does not know that JPA is behind it.

The use case implements the incoming port and calls the outgoing port:

public class CreateOrderService implements CreateOrderUseCase {

    private final OrderRepository orderRepository;

    public CreateOrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Override
    public Order create(CreateOrderCommand command) {
        Order order = Order.create(command.lines());
        orderRepository.save(order);
        return order;
    }
}

Why an interface for the incoming port, when it has only one implementation? It gives the contract a name, and the adapter depends only on this contract. This is also a choice: a lot of code calls the use case class directly. For the outgoing port, the interface is required: it is what lets the core not depend on JPA.

Here: CreateOrderService implements the incoming port and calls the outgoing port. Both interfaces are in the core.

The adapters

The outgoing adapter: JPA

The JPA adapter implements the outgoing port:

@Component
class OrderPersistenceAdapter implements OrderRepository {

    private final OrderJpaRepository jpaRepository;

    OrderPersistenceAdapter(OrderJpaRepository jpaRepository) {
        this.jpaRepository = jpaRepository;
    }

    @Override
    public void save(Order order) {
        jpaRepository.save(OrderMapper.toEntity(order));
    }
}

OrderJpaRepository is a Spring Data interface (JpaRepository<OrderJpaEntity, UUID>). Only the adapter knows it.

The JPA entity is a different class from Order:

@Entity
@Table(name = "orders")
class OrderJpaEntity {

    @Id
    private UUID id;

    @ElementCollection
    @CollectionTable(name = "order_lines", joinColumns = @JoinColumn(name = "order_id"))
    private List<OrderLineEmbeddable> lines = new ArrayList<>();

    protected OrderJpaEntity() {
        // for JPA
    }
    // constructor and getters
}

It has the JPA constraints (a constructor with no arguments, fields that are not final), and the domain does not have them. The mapper goes from one to the other:

static OrderJpaEntity toEntity(Order order) {
    List<OrderLineEmbeddable> lines = order.lines().stream()
            .map(line -> new OrderLineEmbeddable(line.productCode(), line.quantity(), line.unitPrice()))
            .toList();
    return new OrderJpaEntity(order.id(), lines);
}

Here, the mapper goes in one direction only, because the port has only save. A read use case would add the other direction (entity → Order).

The database is H2, in memory. The Spring Boot docs say: “Spring Boot can auto-configure embedded H2, HSQL, and Derby (deprecated) databases. You need not provide any connection URLs.” The tables are also created at startup for an embedded database. You do not need Docker to try the example.

The incoming adapter: REST

The controller does not implement the incoming port. It calls it:

@RestController
@RequestMapping("/api/orders")
class OrderController {

    private final CreateOrderUseCase createOrderUseCase;

    OrderController(CreateOrderUseCase createOrderUseCase) {
        this.createOrderUseCase = createOrderUseCase;
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    OrderResponse create(@RequestBody CreateOrderRequest request) {
        Order order = createOrderUseCase.create(request.toCommand());
        return OrderResponse.from(order);
    }

    @ExceptionHandler(InvalidOrderException.class)
    ProblemDetail invalidOrder(InvalidOrderException exception) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, exception.getMessage());
    }
}

The DTOs CreateOrderRequest and OrderResponse are records, in the adapter package. The DTO-to-command conversion happens here (request.toCommand()), not in the core. A broken rule (InvalidOrderException) becomes a 400 response.

The HTTP direction is not the architecture direction. In HTTP, the request comes in and the response goes out. But for the architecture, the controller does one thing only: it calls the incoming port, and the response is the return value of this call. It is still an incoming adapter.

A test run, with the application started (mvn spring-boot:run):

$ curl -X POST localhost:8080/api/orders -H 'Content-Type: application/json' \
  -d '{"lines":[{"productCode":"BOOK-1","quantity":2,"unitPrice":12.50},{"productCode":"PEN-7","quantity":3,"unitPrice":1.20}]}'
{"id":"45e27a15-2bce-454b-83a4-3b770cf674b3","lines":[{"productCode":"BOOK-1","quantity":2,"unitPrice":12.50},{"productCode":"PEN-7","quantity":3,"unitPrice":1.20}],"total":28.60}

$ curl -X POST localhost:8080/api/orders -H 'Content-Type: application/json' \
  -d '{"lines":[{"productCode":"BOOK-1","quantity":0,"unitPrice":12.50}]}'
{"detail":"quantity must be between 1 and 99, got 0","instance":"/api/orders","status":400,"title":"Bad Request"}

The domain computes the total (28.60). The error message comes from the domain, and the REST adapter turns it into a 400.

Here: the controller calls the incoming port; the JPA adapter implements the outgoing port. The DTOs and the JPA entity stay in their adapters.

The Spring wiring

The core does not know Spring. So who gives an OrderRepository to CreateOrderService? Spring does, through the constructor. The Spring Framework docs recommend it: “The Spring team generally advocates constructor injection, as it lets you implement application components as immutable objects and ensures that required dependencies are not null.” And with a single constructor, you do not need @Autowired: “An @Autowired annotation on such a constructor is not necessary if the target bean defines only one constructor.”

To create the use case with no annotation in the core, a @Configuration class declares the bean:

@Configuration
class OrderConfiguration {

    @Bean
    CreateOrderUseCase createOrderUseCase(OrderRepository orderRepository) {
        return new CreateOrderService(orderRepository);
    }
}

Spring finds the bean that implements OrderRepository (the JPA adapter, marked @Component) and passes it to the method. The controller gets the use case in the same way, through its constructor.

The common variant is shorter: @Service on CreateOrderService, and no configuration class. It works too. Its limit: the core then depends on a Spring annotation. In the repository, an ArchUnit test forbids this. With @Service on the use case, the build fails:

Architecture Violation [Priority: MEDIUM] - Rule 'no classes that reside in any package ['..domain..', '..application..'] should depend on classes that reside in any package ['org.springframework..', 'jakarta..', '..adapter..', '..config..']' was violated (1 times):
Class <dev.aymenjamaaoui.hexagonal.application.CreateOrderService> is annotated with <org.springframework.stereotype.Service> in (CreateOrderService.java:0)

Here: Spring calls nothing and implements nothing in the core. It creates the objects and connects them: the JPA adapter into the use case, the use case into the controller.

Testing without Spring

This is the real gain. The use case depends only on interfaces, so you can test it with a fake outgoing port, in memory.

class InMemoryOrderRepository implements OrderRepository {

    private final List<Order> saved = new ArrayList<>();

    @Override
    public void save(Order order) {
        saved.add(order);
    }

    List<Order> saved() {
        return saved;
    }
}
class CreateOrderServiceTest {

    private final InMemoryOrderRepository repository = new InMemoryOrderRepository();
    private final CreateOrderService service = new CreateOrderService(repository);

    @Test
    void savesAValidOrder() {
        Order order = service.create(new CreateOrderCommand(
                List.of(new OrderLine("BOOK-1", 2, new BigDecimal("12.50")))));

        assertThat(repository.saved()).containsExactly(order);
        assertThat(order.total()).isEqualByComparingTo("25.00");
    }

    @Test
    void savesNothingWhenTheOrderIsInvalid() {
        assertThatThrownBy(() -> service.create(new CreateOrderCommand(List.of())))
                .isInstanceOf(InvalidOrderException.class);

        assertThat(repository.saved()).isEmpty();
    }
}

No Spring, no database, no mock: just new. On my machine, two runs of mvn verify give:

Test classWhat it testsTime
OrderTestthe domain alone0.002 s
CreateOrderServiceTestthe use case, with the fake port0.032 to 0.034 s
OrderApiTestHTTP → controller → use case → JPA → H2 (@SpringBootTest)1.94 to 2.08 s

The end-to-end test is still useful: it checks the wiring. But you can test the rules without starting Spring.

Here: the test calls the incoming port (through the use case), and the fake repository implements the outgoing port, in place of the JPA adapter.

Common mistakes

WrongRight
The REST controller implements the incoming port.The controller calls the incoming port; the use case implements it.
The domain gets the DTO or the JPA entity.The DTO and the JPA entity stay in their adapters; the conversion happens at the border.
The controller is both incoming and outgoing, because the HTTP response “goes out”.The response is the return value of the call to the incoming port. The controller is an incoming adapter.

One point depends on the team’s choices: where the repository interface lives. In this post, it is in application.port.out, and the domain knows nobody. In many DDD projects, it is in the domain. Microsoft’s .NET guide on DDD says: “it’s recommended that you define and place the repository interfaces in the domain model layer”. Both ways work. What matters: the interface belongs to the core, and the implementation is outside.

Here: in both variants, the core calls the interface and the adapter implements it. Only the package of the interface changes.

When not to use it, and a summary sheet

This structure has a cost. For one feature (create an order), the repository has 17 classes in src/main, with 3 for the ports, plus a JPA entity and a mapper next to the domain. For a simple CRUD, with no real business rule, a layered application is enough: there is nothing to protect in the center.

It pays off when the rules grow, when a technology changes (another database, Kafka messages next to REST), or when you want to test the rules fast and often.

Summary sheet

ElementPackageWho calls itWho implements it
Order (domain)domainthe use case— (a class)
CreateOrderUseCase (incoming port)application.port.inOrderControllerCreateOrderService
CreateOrderService (use case)applicationthe controller, through the incoming port—
OrderRepository (outgoing port)application.port.outCreateOrderServiceOrderPersistenceAdapter
OrderController (incoming adapter)adapter.in.webSpring, for each HTTP request—
OrderPersistenceAdapter (outgoing adapter)adapter.out.persistencethe use case, through the outgoing port—

Three rules to keep:

  1. dependencies point to the core;
  2. on the incoming side, the adapter calls and the core implements; on the outgoing side, the core calls and the adapter implements;
  3. DTOs and JPA entities do not cross the border.

To keep rule 1 true over time, an ArchUnit test checks it at each build: see the part “The rule, checked by the build” in the example with Kafka.

Tested with

Java 25 (Temurin 25.0.5), Maven 3.10.0, Spring Boot 4.1.2 (Hibernate 7.4.13), H2 2.4.240 and ArchUnit 1.5.1, on macOS. The code is frozen by the tag v1 of the demo repository.

Sources

Further reading