Hexagonal Architecture in Practice

Hexagonal Architecture — also known as Ports and Adapters — was introduced by Alistair Cockburn with a deceptively simple idea: the application core should have no knowledge of the outside world. Databases, HTTP, message brokers, UI frameworks — all of these are details that should be pluggable from the outside, not baked into your business logic.

After applying this pattern across several enterprise Java projects, I've found it to be one of the highest-leverage architectural decisions a team can make — not because it produces "clean" code in the aesthetic sense, but because it dramatically reduces the cost of change and makes the system genuinely testable without infrastructure.

The Core Idea

The application is organized in three concentric layers:

Driving Side
Application Core
Driven Side
REST Controller
HTTP request
Kafka Consumer
event trigger
CLI
command
→
Input
Port
«interface»
Domain
Core
Entities · Use Cases
Domain Services
no framework imports
→
Output
Port
«interface»
JPA Repository
database
Kafka Producer
messaging
REST Client
external API
← dependency flows inward only →
The core defines interfaces (ports); adapters implement them — dependency always points inward

Project Structure in Spring Boot

src/main/java/com/example/order/ ├── domain/ │ ├── model/ │ │ ├── Order.java # Aggregate root — pure Java, no JPA │ │ ├── OrderItem.java │ │ └── OrderStatus.java # Value object │ └── service/ │ └── OrderDomainService.java │ ├── application/ │ ├── port/ │ │ ├── in/ │ │ │ ├── PlaceOrderUseCase.java # Input port (interface) │ │ │ └── GetOrderUseCase.java │ │ └── out/ │ │ ├── OrderRepository.java # Output port (interface) │ │ └── PaymentGateway.java │ └── service/ │ └── PlaceOrderService.java # Use case implementation │ └── adapter/ ├── in/ │ └── web/ │ └── OrderController.java # REST adapter (calls input port) └── out/ ├── persistence/ │ ├── OrderJpaRepository.java │ └── OrderPersistenceAdapter.java # Implements OrderRepository port └── messaging/ └── PaymentGatewayAdapter.java # Implements PaymentGateway port

Defining Ports

Ports are plain Java interfaces owned by the application layer. They express intent, not implementation.

// Input port — what the application offers to the outside world public interface PlaceOrderUseCase { OrderId placeOrder(PlaceOrderCommand command); } // Output port — what the application needs from the outside world public interface OrderRepository { void save(Order order); Optional<Order> findById(OrderId id); } public interface PaymentGateway { PaymentResult charge(CustomerId customerId, Money amount); }

The Domain Model: No Framework Contamination

The most important discipline: domain entities must not import anything from Spring, JPA, or any other framework. They are plain Java objects that encode business rules.

// Pure domain entity — no @Entity, no @Column, no Spring annotations public class Order { private final OrderId id; private final CustomerId customerId; private final List<OrderItem> items; private OrderStatus status; private Order(OrderId id, CustomerId customerId, List<OrderItem> items) { this.id = id; this.customerId = customerId; this.items = new ArrayList<>(items); this.status = OrderStatus.PENDING; } public static Order create(CustomerId customerId, List<OrderItem> items) { if (items.isEmpty()) throw new DomainException("Order must have at least one item"); return new Order(OrderId.generate(), customerId, items); } public void confirm() { if (this.status != OrderStatus.PENDING) throw new DomainException("Only pending orders can be confirmed"); this.status = OrderStatus.CONFIRMED; } public Money totalAmount() { return items.stream() .map(OrderItem::subtotal) .reduce(Money.ZERO, Money::add); } }

Use Case Implementation

The use case orchestrates the domain and calls output ports. It has no knowledge of HTTP, JPA, or Kafka — only the interfaces.

@Service @Transactional @RequiredArgsConstructor public class PlaceOrderService implements PlaceOrderUseCase { private final OrderRepository orderRepository; // output port — injected private final PaymentGateway paymentGateway; // output port — injected @Override public OrderId placeOrder(PlaceOrderCommand command) { Order order = Order.create(command.customerId(), command.items()); PaymentResult payment = paymentGateway.charge( command.customerId(), order.totalAmount() ); if (!payment.isSuccessful()) { throw new PaymentFailedException(payment.reason()); } order.confirm(); orderRepository.save(order); return order.getId(); } }

Adapters: Connecting to the Infrastructure

Persistence Adapter (JPA)

// JPA entity — separate from the domain entity @Entity @Table(name = "orders") class OrderJpaEntity { @Id String id; String customerId; String status; // ... JPA-specific fields } // The adapter translates between domain model and JPA entity @Component @RequiredArgsConstructor class OrderPersistenceAdapter implements OrderRepository { private final OrderJpaRepository jpaRepository; private final OrderMapper mapper; @Override public void save(Order order) { jpaRepository.save(mapper.toJpaEntity(order)); } @Override public Optional<Order> findById(OrderId id) { return jpaRepository.findById(id.value()) .map(mapper::toDomainEntity); } }

REST Adapter (Spring MVC)

@RestController @RequestMapping("/api/orders") @RequiredArgsConstructor public class OrderController { private final PlaceOrderUseCase placeOrderUseCase; // input port @PostMapping public ResponseEntity<PlaceOrderResponse> placeOrder( @RequestBody @Valid PlaceOrderRequest request) { PlaceOrderCommand command = PlaceOrderCommand.from(request); OrderId orderId = placeOrderUseCase.placeOrder(command); return ResponseEntity .created(URI.create("/api/orders/" + orderId.value())) .body(new PlaceOrderResponse(orderId.value())); } }

The Testability Payoff

The real return on this investment shows up in the test suite. Because the domain has no infrastructure dependencies, unit tests are fast, focused, and free of Spring context startup.

class PlaceOrderServiceTest { // No Spring context, no database, no Kafka private final OrderRepository orderRepository = mock(OrderRepository.class); private final PaymentGateway paymentGateway = mock(PaymentGateway.class); private final PlaceOrderService service = new PlaceOrderService(orderRepository, paymentGateway); @Test void shouldConfirmOrderWhenPaymentSucceeds() { given(paymentGateway.charge(any(), any())) .willReturn(PaymentResult.success()); OrderId orderId = service.placeOrder(validCommand()); assertThat(orderId).isNotNull(); verify(orderRepository).save(argThat(o -> o.getStatus() == OrderStatus.CONFIRMED )); } @Test void shouldThrowWhenPaymentFails() { given(paymentGateway.charge(any(), any())) .willReturn(PaymentResult.failure("Insufficient funds")); assertThatThrownBy(() -> service.placeOrder(validCommand())) .isInstanceOf(PaymentFailedException.class); verify(orderRepository, never()).save(any()); } }

Result: The entire use case logic runs in milliseconds with no infrastructure. Integration tests (using Testcontainers) are reserved for verifying the adapters — not the business rules. This separation typically reduces test suite runtime by 60–80% compared to testing everything through the Spring context.

When Not to Use It

Hexagonal Architecture has a real cost: more files, more interfaces, more mapping code. It is the right trade-off when:

For CRUD-heavy microservices with minimal business logic, a simpler layered architecture (controller → service → repository) will often serve better. Hexagonal architecture is a scalpel, not a hammer.

Key Takeaways

Back to all articles