Post

Hexagonal Architecture (Ports & Adapters) with Spring Boot

Structure Spring Boot applications using Hexagonal Architecture. Covers ports (interfaces), adapters (implementations), domain isolation, dependency inversion, and how to test each layer independently.

Hexagonal Architecture (Ports & Adapters) with Spring Boot

The Problem — Tangled Layers

Most Spring Boot applications start with a clean layered structure: Controller → Service → Repository. Over time, the boundaries blur:

  • Services import JPA annotations for custom queries
  • Domain logic leaks into controllers
  • External API calls are embedded in service methods
  • Tests require a full Spring context just to test business rules

The result is a codebase where changing your database means rewriting business logic, and swapping a payment provider touches twenty files.

graph TD
    subgraph "Tangled Layered Architecture"
        A[Controller] -->|JPA Entity exposed| B[Service]
        B -->|Hibernate annotations| C[Repository]
        B -->|Direct HTTP call| D[External API]
        A -->|Business logic leaked| A
    end

diagram


What is Hexagonal Architecture?

Hexagonal Architecture (also called Ports & Adapters), proposed by Alistair Cockburn, places the domain at the center. Everything else — databases, web frameworks, external APIs — connects through ports (interfaces) and adapters (implementations).

graph TB
    subgraph "Adapters IN"
        WEB[REST Controller]
        CLI[CLI Runner]
        MSG[Message Consumer]
    end

    subgraph "Ports IN"
        UC1[CreateOrderUseCase]
        UC2[GetOrderUseCase]
    end

    subgraph "Domain Core"
        DS[OrderService]
        DM[Order Entity]
        VS[Value Objects]
    end

    subgraph "Ports OUT"
        RP[OrderRepository]
        PG[PaymentGateway]
    end

    subgraph "Adapters OUT"
        JPA[JPA Repository]
        STRIPE[Stripe Gateway]
        MOCK[Mock Gateway]
    end

    WEB --> UC1
    CLI --> UC2
    MSG --> UC1
    UC1 --> DS
    UC2 --> DS
    DS --> DM
    DS --> RP
    DS --> PG
    RP -.->|implements| JPA
    PG -.->|implements| STRIPE
    PG -.->|implements| MOCK

diagram

The key rule: dependencies point inward. The domain never knows about Spring, JPA, or HTTP.


Package Structure

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
src/main/java/com/anupam/hexagonal/
├── domain/
│   ├── model/
│   │   ├── Order.java              # Domain entity (pure Java)
│   │   └── OrderStatus.java        # Domain enum
│   ├── port/
│   │   ├── in/
│   │   │   ├── CreateOrderUseCase.java   # Input port
│   │   │   └── GetOrderUseCase.java      # Input port
│   │   └── out/
│   │       ├── OrderRepository.java      # Output port
│   │       └── PaymentGateway.java       # Output port
│   └── service/
│       └── OrderService.java       # Implements use cases
├── adapter/
│   ├── in/
│   │   └── web/
│   │       └── OrderController.java     # REST adapter
│   └── out/
│       ├── persistence/
│       │   ├── JpaOrderRepository.java  # JPA adapter
│       │   └── OrderEntity.java         # JPA entity
│       └── payment/
│           └── StripePaymentGateway.java # Payment adapter
└── HexagonalApplication.java

Notice: the domain package has zero Spring imports. The adapter package contains all framework-specific code.


Domain Layer — Pure Java, No Spring

Domain Entity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class Order {

    private final String id;
    private final String customerId;
    private final List<LineItem> items;
    private OrderStatus status;
    private final Instant createdAt;

    /** Factory method for creating new orders */
    public static Order create(String customerId, List<LineItem> items) {
        if (items == null || items.isEmpty()) {
            throw new IllegalArgumentException("Order must have at least one item");
        }
        return new Order(UUID.randomUUID().toString(), customerId, items,
            OrderStatus.PENDING, Instant.now());
    }

    /** Domain logic: calculate total */
    public BigDecimal totalAmount() {
        return items.stream()
            .map(LineItem::subtotal)
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }

    /** Domain logic: state transition with invariant checking */
    public void confirm() {
        if (status != OrderStatus.PAYMENT_PROCESSING) {
            throw new IllegalStateException("Cannot confirm order in status: " + status);
        }
        this.status = OrderStatus.CONFIRMED;
    }

    public void startPayment() {
        if (status != OrderStatus.PENDING) {
            throw new IllegalStateException("Cannot start payment in status: " + status);
        }
        this.status = OrderStatus.PAYMENT_PROCESSING;
    }

    public record LineItem(String productId, String productName,
                           int quantity, BigDecimal unitPrice) {
        public BigDecimal subtotal() {
            return unitPrice.multiply(BigDecimal.valueOf(quantity));
        }
    }
}

No @Entity, no @Column, no @Service. This is plain Java that can be tested with JUnit alone.


Input Ports + Adapters

Input Port (Use Case Interface)

1
2
3
4
5
6
7
8
9
10
11
12
13
public interface CreateOrderUseCase {

    Order createOrder(CreateOrderCommand command);

    record CreateOrderCommand(
        String customerId,
        List<OrderItemCommand> items
    ) {
        public record OrderItemCommand(
            String productId, String productName, int quantity, BigDecimal unitPrice
        ) {}
    }
}

The port defines what the application can do. It lives in the domain package.

Input Adapter (REST Controller)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final CreateOrderUseCase createOrderUseCase;
    private final GetOrderUseCase getOrderUseCase;

    public OrderController(CreateOrderUseCase createOrderUseCase,
                           GetOrderUseCase getOrderUseCase) {
        this.createOrderUseCase = createOrderUseCase;
        this.getOrderUseCase = getOrderUseCase;
    }

    @PostMapping
    public ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest request) {
        var command = new CreateOrderCommand(
            request.customerId(),
            request.items().stream()
                .map(i -> new OrderItemCommand(i.productId(), i.productName(),
                    i.quantity(), i.unitPrice()))
                .toList()
        );
        Order order = createOrderUseCase.createOrder(command);
        return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(order));
    }
}

The controller depends on the port (interface), not on OrderService directly. If you swap the service implementation, the controller doesn’t change.


Output Ports + Adapters

Output Port (Repository Interface in Domain)

1
2
3
4
5
public interface OrderRepository {
    Order save(Order order);
    Optional<Order> findById(String orderId);
    List<Order> findByCustomerId(String customerId);
}

This is NOT a Spring Data interface. It’s defined by the domain, describing what the domain needs.

Output Adapter (JPA Implementation)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Repository
public class JpaOrderRepository implements OrderRepository {

    private final SpringDataOrderRepository springRepo;
    private final ObjectMapper objectMapper;

    @Override
    public Order save(Order order) {
        OrderEntity entity = toEntity(order);
        springRepo.save(entity);
        return order;
    }

    @Override
    public Optional<Order> findById(String orderId) {
        return springRepo.findById(orderId).map(this::toDomain);
    }

    private OrderEntity toEntity(Order order) {
        // Maps domain model → JPA entity
    }

    private Order toDomain(OrderEntity entity) {
        // Maps JPA entity → domain model
    }
}

The adapter handles the mapping between the domain model and the persistence model. The domain never sees @Entity or Hibernate.


The Dependency Rule

graph LR
    subgraph "Outer Ring — Adapters"
        A[REST Controller]
        B[JPA Repository]
        C[Stripe Gateway]
    end

    subgraph "Middle Ring — Ports"
        D[CreateOrderUseCase]
        E[OrderRepository]
        F[PaymentGateway]
    end

    subgraph "Inner Ring — Domain"
        G[Order]
        H[OrderService]
    end

    A -->|depends on| D
    B -->|implements| E
    C -->|implements| F
    H -->|uses| E
    H -->|uses| F
    H -->|creates| G
    D -->|implemented by| H

diagram

Arrows always point inward. Outer layers depend on inner layers. Inner layers define interfaces (ports) that outer layers implement.


Testing Each Layer

Unit Test Domain (No Spring Context)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class OrderTest {

    @Test
    void shouldCalculateTotal() {
        var items = List.of(
            new Order.LineItem("p1", "Widget", 2, new BigDecimal("10.00")),
            new Order.LineItem("p2", "Gadget", 1, new BigDecimal("25.00"))
        );
        Order order = Order.create("customer-1", items);

        assertThat(order.totalAmount()).isEqualByComparingTo("45.00");
    }

    @Test
    void shouldRejectEmptyItems() {
        assertThatThrownBy(() -> Order.create("customer-1", List.of()))
            .isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void shouldEnforceStateTransitions() {
        var items = List.of(new Order.LineItem("p1", "Widget", 1, BigDecimal.TEN));
        Order order = Order.create("customer-1", items);

        assertThatThrownBy(order::confirm)
            .isInstanceOf(IllegalStateException.class);
    }
}

Unit Test Service (Mock Ports)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock OrderRepository orderRepository;
    @Mock PaymentGateway paymentGateway;
    @InjectMocks OrderService orderService;

    @Test
    void shouldCreateOrderAndChargePayment() {
        when(paymentGateway.charge(any(), any(), any()))
            .thenReturn(new PaymentResult("txn-1", PaymentStatus.SUCCESS));
        when(orderRepository.save(any())).thenAnswer(i -> i.getArgument(0));

        var command = new CreateOrderCommand("cust-1", List.of(
            new OrderItemCommand("p1", "Widget", 2, BigDecimal.TEN)
        ));

        Order result = orderService.createOrder(command);

        assertThat(result.getStatus()).isEqualTo(OrderStatus.CONFIRMED);
        verify(paymentGateway).charge("cust-1", new BigDecimal("20.00"), "USD");
    }
}

Integration Test Adapter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@DataJpaTest
class JpaOrderRepositoryTest {

    @Autowired SpringDataOrderRepository springRepo;
    private JpaOrderRepository repository;

    @BeforeEach
    void setUp() {
        repository = new JpaOrderRepository(springRepo, new ObjectMapper());
    }

    @Test
    void shouldSaveAndRetrieveOrder() {
        Order order = Order.create("cust-1", List.of(
            new Order.LineItem("p1", "Widget", 1, BigDecimal.TEN)
        ));

        repository.save(order);
        Optional<Order> found = repository.findById(order.getId());

        assertThat(found).isPresent();
        assertThat(found.get().getCustomerId()).isEqualTo("cust-1");
    }
}

Architecture Comparison

AspectLayeredHexagonalOnion
Dependency directionTop-down (Controller → Service → Repo)Inward (Adapters → Ports → Domain)Inward (Infra → Application → Domain)
Domain purityOften polluted with JPA/SpringFramework-freeFramework-free
TestabilityRequires Spring context for most testsDomain testable without frameworkDomain testable without framework
ComplexityLowMediumMedium-High
When to useSimple CRUD, small teamsMultiple adapters, testability focusComplex domain, DDD focus
Package structureBy layer (controller/, service/, repo/)By feature with layers insideConcentric rings

Common Problems

ProblemCauseSolution
Domain imports Spring annotationsDeveloper shortcutEnforce with ArchUnit tests
Adapter-to-adapter callsBypassing domainRoute all logic through use cases
Mapping boilerplateEntity ↔ Domain conversionsUse MapStruct or explicit mapper classes
Too many ports for simple CRUDOver-engineeringStart with one use case interface per aggregate
Domain service becomes a God classToo many use cases in one classSplit into focused services per aggregate action

ArchUnit test to enforce the dependency rule:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@AnalyzeClasses(packages = "com.anupam.hexagonal")
class ArchitectureTest {

    @ArchTest
    static final ArchRule domainShouldNotDependOnAdapters =
        noClasses().that().resideInAPackage("..domain..")
            .should().dependOnClassesThat()
            .resideInAPackage("..adapter..");

    @ArchTest
    static final ArchRule domainShouldNotDependOnSpring =
        noClasses().that().resideInAPackage("..domain..")
            .should().dependOnClassesThat()
            .resideInAPackage("org.springframework..");
}

Full Working Example

The complete source code is available on GitHub:

spring-boot-hexagonal

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Run the application (H2 in-memory database)
./mvnw spring-boot:run

# Create an order
curl -X POST http://localhost:8080/api/orders \
  -H "Content-Type: application/json" \
  -d '{
    "customerId": "cust-42",
    "items": [
      {"productId": "p1", "productName": "Widget", "quantity": 2, "unitPrice": 10.00}
    ]
  }'

# Get orders by customer
curl http://localhost:8080/api/orders/customer/cust-42

References

This post is licensed under CC BY 4.0 by the author.