Senior Java Guide • 2026

Hexagonal Architecture (Ports & Adapters) for Senior Java Developers

A practical, modern guide to understanding Hexagonal Architecture deeply, applying it with current Java and Spring, avoiding over-engineering, and using it in a way that actually improves maintainability, testability, and system evolution.

Core idea

Business logic stays in the center. UI, database, messaging, and external systems stay outside and connect through ports.

Why it matters

It protects the domain from framework and infrastructure churn, making systems easier to test and evolve.

2026 reality

Hexagonal is still strong, but best results usually come when combined with domain-driven packaging and modular monolith thinking.

Contents

1. What Hexagonal Architecture really is

Hexagonal Architecture, also called Ports and Adapters, was introduced by Alistair Cockburn. The goal is simple: keep business logic independent from delivery mechanisms and infrastructure. That means your core rules should not depend directly on Spring MVC, JPA, Kafka, REST, GraphQL, PostgreSQL, or any vendor SDK.

The key dependency rule: code in the center must not depend on technical details outside the center. Outside code may depend on the inside. Not the other way around.

The “hexagon” shape is symbolic only. It does not mean six sides are required. It simply shows that your application can talk to many external actors through clearly defined boundaries.

Outside World ├─ REST API adapter ├─ CLI adapter ├─ Kafka consumer adapter ├─ JPA adapter ├─ External payment adapter └─ Test adapter │ ▼ +-----------------------+ | Application Core | | Domain + Use Cases | | Inbound Ports | | Outbound Ports | +-----------------------+

2. Ports and adapters explained simply

TermMeaningExample
Inbound port An interface the application exposes for others to use. CreateOrderUseCase
Outbound port An interface the application needs from the outside world. OrderRepository, PaymentGateway
Inbound adapter Transforms external input into a call to an inbound port. REST controller, Kafka listener, scheduled job, CLI command
Outbound adapter Implements an outbound port using a concrete technology. JPA repository adapter, HTTP client adapter, S3 adapter
Application service Coordinates a use case. Usually sits in the core. PlaceOrderService
Domain model Pure business concepts and rules. Order, Money, OrderStatus
Easy way to remember: inbound ports say “what the app can do”; outbound ports say “what the app needs from others.”

3. The modern 2026 view

Hexagonal Architecture is still highly relevant, but experienced teams now apply it more carefully. The architecture itself has not been replaced. What changed is how people use it.

Senior-level warning: Hexagonal Architecture is not about creating five extra interfaces around every class. It is about controlling coupling at the right boundaries.

What is “latest” around it?

In the current Spring ecosystem, Spring Boot 4.x and Spring Framework 7.x are available, while Spring Modulith is increasingly used to enforce domain/module boundaries in larger applications. That makes a strong combination: Hexagonal inside each business module, Modulith across modules.

4. Recommended Java package structure

For a real-world Java system, this is a practical structure:

com.acme.orders
├── order
│   ├── domain
│   │   ├── Order.java
│   │   ├── OrderId.java
│   │   ├── OrderStatus.java
│   │   └── Money.java
│   ├── application
│   │   ├── port
│   │   │   ├── in
│   │   │   │   └── PlaceOrderUseCase.java
│   │   │   └── out
│   │   │       ├── LoadOrderPort.java
│   │   │       ├── SaveOrderPort.java
│   │   │       └── PublishOrderPlacedPort.java
│   │   └── service
│   │       └── PlaceOrderService.java
│   ├── adapter
│   │   ├── in
│   │   │   └── web
│   │   │       └── OrderController.java
│   │   └── out
│   │       ├── persistence
│   │       │   ├── OrderJpaEntity.java
│   │       │   ├── SpringDataOrderRepository.java
│   │       │   └── OrderPersistenceAdapter.java
│   │       └── messaging
│   │           └── KafkaOrderPlacedAdapter.java
│   └── config
│       └── OrderBeanConfig.java
└── Application.java
Why this structure works well: it groups code by business capability first, then by architectural role. This scales better than controller/service/repository packages across the whole codebase.

5. End-to-end Java example

Below is a modern example using Java 21 style. It keeps the domain clean, uses ports explicitly, and keeps Spring/JPA only in adapters and configuration.

5.1 Domain model

package com.acme.orders.order.domain;

import java.math.BigDecimal;
import java.util.List;
import java.util.Objects;
import java.util.UUID;

public record OrderId(UUID value) {
    public static OrderId newId() {
        return new OrderId(UUID.randomUUID());
    }
}

public record Money(BigDecimal amount) {
    public Money {
        Objects.requireNonNull(amount, "amount must not be null");
        if (amount.signum() < 0) {
            throw new IllegalArgumentException("Money cannot be negative");
        }
    }

    public Money add(Money other) {
        return new Money(this.amount.add(other.amount));
    }
}

public record OrderLine(String productCode, int quantity, Money unitPrice) {
    public OrderLine {
        if (productCode == null || productCode.isBlank()) throw new IllegalArgumentException("productCode is required");
        if (quantity <= 0) throw new IllegalArgumentException("quantity must be > 0");
        Objects.requireNonNull(unitPrice, "unitPrice is required");
    }

    public Money lineTotal() {
        return new Money(unitPrice.amount().multiply(BigDecimal.valueOf(quantity)));
    }
}

public enum OrderStatus {
    CREATED, CONFIRMED, REJECTED
}

public final class Order {
    private final OrderId id;
    private final String customerId;
    private final List<OrderLine> lines;
    private OrderStatus status;

    private Order(OrderId id, String customerId, List<OrderLine> lines, OrderStatus status) {
        this.id = id;
        this.customerId = customerId;
        this.lines = List.copyOf(lines);
        this.status = status;
        validate();
    }

    public static Order create(String customerId, List<OrderLine> lines) {
        return new Order(OrderId.newId(), customerId, lines, OrderStatus.CREATED);
    }

    private void validate() {
        if (customerId == null || customerId.isBlank()) throw new IllegalArgumentException("customerId is required");
        if (lines == null || lines.isEmpty()) throw new IllegalArgumentException("order must contain at least one line");
    }

    public Money total() {
        return lines.stream()
                .map(OrderLine::lineTotal)
                .reduce(new Money(BigDecimal.ZERO), Money::add);
    }

    public void confirm() {
        if (total().amount().compareTo(BigDecimal.ZERO) == 0) {
            throw new IllegalStateException("Cannot confirm an order with zero total");
        }
        this.status = OrderStatus.CONFIRMED;
    }

    public OrderId id() { return id; }
    public String customerId() { return customerId; }
    public List<OrderLine> lines() { return lines; }
    public OrderStatus status() { return status; }
}

5.2 Inbound and outbound ports

package com.acme.orders.order.application.port.in;

import java.util.List;

public interface PlaceOrderUseCase {
    PlaceOrderResult placeOrder(PlaceOrderCommand command);

    record PlaceOrderCommand(String customerId, List<OrderItem> items) {}
    record OrderItem(String productCode, int quantity, String unitPrice) {}
    record PlaceOrderResult(String orderId, String status, String total) {}
}
package com.acme.orders.order.application.port.out;

import com.acme.orders.order.domain.Order;
import com.acme.orders.order.domain.OrderId;
import java.util.Optional;

public interface LoadOrderPort {
    Optional<Order> load(OrderId orderId);
}

public interface SaveOrderPort {
    void save(Order order);
}

public interface PublishOrderPlacedPort {
    void publish(Order order);
}

5.3 Application service

package com.acme.orders.order.application.service;

import com.acme.orders.order.application.port.in.PlaceOrderUseCase;
import com.acme.orders.order.application.port.out.PublishOrderPlacedPort;
import com.acme.orders.order.application.port.out.SaveOrderPort;
import com.acme.orders.order.domain.Money;
import com.acme.orders.order.domain.Order;
import com.acme.orders.order.domain.OrderLine;

import java.math.BigDecimal;
import java.util.List;

public class PlaceOrderService implements PlaceOrderUseCase {

    private final SaveOrderPort saveOrderPort;
    private final PublishOrderPlacedPort publishOrderPlacedPort;

    public PlaceOrderService(SaveOrderPort saveOrderPort,
                             PublishOrderPlacedPort publishOrderPlacedPort) {
        this.saveOrderPort = saveOrderPort;
        this.publishOrderPlacedPort = publishOrderPlacedPort;
    }

    @Override
    public PlaceOrderResult placeOrder(PlaceOrderCommand command) {
        List<OrderLine> lines = command.items().stream()
                .map(i -> new OrderLine(
                        i.productCode(),
                        i.quantity(),
                        new Money(new BigDecimal(i.unitPrice()))))
                .toList();

        Order order = Order.create(command.customerId(), lines);
        order.confirm();

        saveOrderPort.save(order);
        publishOrderPlacedPort.publish(order);

        return new PlaceOrderResult(
                order.id().value().toString(),
                order.status().name(),
                order.total().amount().toPlainString());
    }
}

5.4 Inbound web adapter

package com.acme.orders.order.adapter.in.web;

import com.acme.orders.order.application.port.in.PlaceOrderUseCase;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;

import java.util.List;

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

    private final PlaceOrderUseCase placeOrderUseCase;

    public OrderController(PlaceOrderUseCase placeOrderUseCase) {
        this.placeOrderUseCase = placeOrderUseCase;
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public PlaceOrderUseCase.PlaceOrderResult placeOrder(@Valid @RequestBody PlaceOrderRequest request) {
        var command = new PlaceOrderUseCase.PlaceOrderCommand(
                request.customerId(),
                request.items().stream()
                        .map(i -> new PlaceOrderUseCase.OrderItem(i.productCode(), i.quantity(), i.unitPrice()))
                        .toList());

        return placeOrderUseCase.placeOrder(command);
    }

    public record PlaceOrderRequest(
            @NotBlank String customerId,
            @NotEmpty List<PlaceOrderItemRequest> items
    ) {}

    public record PlaceOrderItemRequest(
            @NotBlank String productCode,
            @Min(1) int quantity,
            @NotBlank String unitPrice
    ) {}
}

5.5 Outbound persistence adapter

package com.acme.orders.order.adapter.out.persistence;

import com.acme.orders.order.application.port.out.SaveOrderPort;
import com.acme.orders.order.domain.Order;
import org.springframework.stereotype.Component;

@Component
public class OrderPersistenceAdapter implements SaveOrderPort {

    private final SpringDataOrderRepository repository;

    public OrderPersistenceAdapter(SpringDataOrderRepository repository) {
        this.repository = repository;
    }

    @Override
    public void save(Order order) {
        OrderJpaEntity entity = OrderJpaEntity.fromDomain(order);
        repository.save(entity);
    }
}

5.6 Outbound messaging adapter

package com.acme.orders.order.adapter.out.messaging;

import com.acme.orders.order.application.port.out.PublishOrderPlacedPort;
import com.acme.orders.order.domain.Order;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

@Component
public class OrderPlacedLogAdapter implements PublishOrderPlacedPort {

    private static final Logger log = LoggerFactory.getLogger(OrderPlacedLogAdapter.class);

    @Override
    public void publish(Order order) {
        log.info("Order placed: id={}, customerId={}, total={}",
                order.id().value(), order.customerId(), order.total().amount());
    }
}

5.7 Spring wiring

package com.acme.orders.order.config;

import com.acme.orders.order.application.port.in.PlaceOrderUseCase;
import com.acme.orders.order.application.port.out.PublishOrderPlacedPort;
import com.acme.orders.order.application.port.out.SaveOrderPort;
import com.acme.orders.order.application.service.PlaceOrderService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class OrderBeanConfig {

    @Bean
    PlaceOrderUseCase placeOrderUseCase(SaveOrderPort saveOrderPort,
                                        PublishOrderPlacedPort publishOrderPlacedPort) {
        return new PlaceOrderService(saveOrderPort, publishOrderPlacedPort);
    }
}
Important detail: the application service is plain Java. It does not require @Service to exist. Spring is only used to wire dependencies together.

5.8 Using sealed interfaces for richer outcomes

For complex flows, Java sealed interfaces can make use case results clearer:

public sealed interface PaymentResult permits PaymentAccepted, PaymentRejected, PaymentTimedOut {}

public record PaymentAccepted(String authorizationId) implements PaymentResult {}
public record PaymentRejected(String reason) implements PaymentResult {}
public record PaymentTimedOut() implements PaymentResult {}

This is often better than returning booleans or nullable fields.

6. How to test it properly

6.1 Fast unit tests for the core

The main advantage of hexagonal architecture is that the core can be tested without Spring, database, or network.

class PlaceOrderServiceTest {

    @Test
    void should_place_and_confirm_order() {
        SaveOrderPort saveOrderPort = order -> {};
        PublishOrderPlacedPort publishPort = order -> {};
        PlaceOrderService service = new PlaceOrderService(saveOrderPort, publishPort);

        var result = service.placeOrder(new PlaceOrderUseCase.PlaceOrderCommand(
                "CUST-100",
                List.of(new PlaceOrderUseCase.OrderItem("BOOK", 2, "25.00"))));

        assertEquals("CONFIRMED", result.status());
        assertEquals("50.00", result.total());
    }
}

6.2 Adapter tests

6.3 Why seniors like this

You can isolate failures better. If a test fails, you know whether the problem is in the domain, the web translation, persistence mapping, or integration boundary.

7. Common mistakes

MistakeWhy it hurtsBetter approach
Putting JPA entities in the domain Domain becomes persistence-driven and harder to test/change Keep JPA entities in the persistence adapter
Creating interfaces for everything Noise and ceremony without architectural value Create ports only at true boundaries
Using anemic domain models Business rules leak into services/controllers Put behavior and invariants in domain objects where it makes sense
Package-by-layer only Feature cohesion gets weak in large systems Package by feature/domain first
Thinking hexagonal means microservices It is a code structure pattern, not a deployment strategy Use it inside monoliths or services
Returning REST DTOs from the core HTTP concerns leak inward Use application commands/results or domain types

8. Hexagonal vs layered vs clean vs modulith

StyleMain focusBest useWeakness if misused
Traditional layered Controller → service → repository separation Simple CRUD apps Business logic often leaks and gets tightly coupled to infrastructure
Hexagonal Protecting the core from outside concerns Apps with meaningful domain rules and integrations Can become too abstract if overdone
Clean architecture Dependency rule across concentric boundaries Conceptually similar to hexagonal, often broader in presentation Can become theoretical and verbose
Onion architecture Domain-centered layering DDD-oriented design Terminology overlaps can confuse teams
Modular monolith / Spring Modulith Enforcing module boundaries inside one deployable app Large Spring systems that are not yet microservices Does not replace careful internal design of each module
Practical senior recommendation: use domain-based modularization at the application level, and hexagonal boundaries where modules talk to the outside world.

9. Self-critique and refinement

To make this guide stronger, here is a direct self-review:

Initial weaknesses found

  1. Risk of being too abstract: architecture articles often explain theory but do not show enough real code.
  2. Risk of sounding outdated: many hexagonal examples still use old package structures or framework-heavy service classes.
  3. Risk of over-selling: hexagonal architecture is useful, but not every small app needs the full pattern.

How this guide corrects those weaknesses

Final refined takeaway: Hexagonal Architecture is still one of the best ways to keep a Java system maintainable when business rules matter, integrations change, and long-term evolution is expected. The mature 2026 version is not “hexagonal everywhere”; it is “hexagonal where the boundary matters.”

10. References

Tip for publishing: you can split this page later into multiple pages such as “concepts”, “code examples”, “testing”, and “anti-patterns”, but this single-page version is ideal as a strong academy starter page.

Project Repository

View the full source code for this Hexagonal Architecture example on GitHub.

Open GitHub Repository