Hexagonal Architecture with Spring Boot — Build Clean, Maintainable Applications
A practical guide to Ports and Adapters (Hexagonal Architecture) in Spring Boot — isolate your domain, make your code testable, and keep infrastructure swappable.
Introduction
Most Spring Boot codebases follow the same shape: Controller → Service → Repository → Entity. It works — until the day you need to swap Postgres for DynamoDB, add a second delivery channel (Kafka consumer, gRPC, scheduled job), or test a complex business rule without booting the whole Spring context.
Hexagonal Architecture — also known as Ports and Adapters, coined by Alistair Cockburn in 2005 — gives you a structure that survives those changes. The domain sits in the center. Everything else (the database, the HTTP layer, the message broker) is a swappable adapter.
Key takeaways
- The domain depends on nothing. Everything depends on the domain.
- Ports are interfaces the domain owns; adapters are implementations.
- Inbound adapters (REST, Kafka consumer) call inbound ports.
- The domain calls outbound ports; outbound adapters (JPA, S3) implement them.
- The payoff: business logic you can unit-test in milliseconds, and infrastructure you can replace without touching a single rule.
The classic layered architecture — and why it leaks
Controller ──► Service ──► Repository ──► JPA Entity
(also returned to controller)
The problem is the arrows pointing *into* the domain from infrastructure. The service depends on OrderRepository which depends on Spring Data JPA. The entity is annotated with @Entity and serialized straight to JSON. Your business rules now know about Hibernate, Jackson, and your HTTP framework.
Hexagonal: invert the dependencies
┌────────────────────────┐
HTTP ──► REST│ inbound port │
│ (interface) │
│ │ │
│ ▼ │
Kafka ──►Consumer ┌──────────┐ │
│ │ DOMAIN │ │
│ │ + USE │ │
│ │ CASES │ │
│ └────┬─────┘ │
│ │ │
│ ▼ │
│ outbound port │
│ (interface) │
└────────┬───┬───────────┘
│ │
┌────▼┐ ┌▼──────┐
│ JPA │ │ Email │ outbound adapters
└─────┘ └───────┘
Every arrow points toward the domain. The domain defines what it needs (OrderRepository is an interface owned by the domain) and infrastructure adapts.
Step 1 — Project structure
com.acme.orders
├── domain/ ← pure Java. no Spring, no JPA, no Jackson.
│ ├── model/
│ │ ├── Order.java
│ │ ├── OrderId.java
│ │ └── Money.java
│ └── port/
│ ├── in/PlaceOrderUseCase.java ← inbound port
│ └── out/OrderRepository.java ← outbound port
├── application/
│ └── PlaceOrderService.java ← implements inbound port
└── infrastructure/
├── rest/OrderController.java ← inbound adapter
├── persistence/
│ ├── OrderJpaEntity.java
│ ├── OrderJpaRepository.java
│ └── OrderJpaAdapter.java ← implements outbound port
└── messaging/PaymentClient.java
Step 2 — The domain (no framework)
```java
package com.acme.orders.domain.model;public final class Order { private final OrderId id; private final CustomerId customer; private final List<OrderLine> lines; private OrderStatus status;
public static Order place(CustomerId customer, List<OrderLine> lines) { if (lines.isEmpty()) throw new IllegalArgumentException("Order needs at least one line"); return new Order(OrderId.generate(), customer, List.copyOf(lines), OrderStatus.PLACED); }
public void cancel() { if (status == OrderStatus.SHIPPED) throw new IllegalStateException("Shipped orders cannot be cancelled"); this.status = OrderStatus.CANCELLED; }
public Money total() { return lines.stream().map(OrderLine::subtotal).reduce(Money.ZERO, Money::add); } // ... } ```
No @Entity, no @Component, no Lombok if you can avoid it. This class can be tested with plain JUnit in microseconds.
Step 3 — Ports
```java
// inbound port — what the outside world can ask the application to do
package com.acme.orders.domain.port.in;public interface PlaceOrderUseCase { OrderId place(PlaceOrderCommand cmd); }
public record PlaceOrderCommand(CustomerId customer, List<OrderLine> lines) {} ```
```java
// outbound port — what the application needs from the outside world
package com.acme.orders.domain.port.out;public interface OrderRepository { void save(Order order); Optional<Order> findById(OrderId id); } ```
Step 4 — Application service (use case)
```java
package com.acme.orders.application;@Service @RequiredArgsConstructor class PlaceOrderService implements PlaceOrderUseCase { private final OrderRepository repository; private final InventoryPort inventory;
@Transactional public OrderId place(PlaceOrderCommand cmd) { inventory.reserve(cmd.lines()); Order order = Order.place(cmd.customer(), cmd.lines()); repository.save(order); return order.id(); } } ```
The service orchestrates. The rules ("at least one line", "shipped cannot cancel") live on the aggregate. The service is stupid; the domain is smart.
Step 5 — Inbound adapter (REST)
```java
package com.acme.orders.infrastructure.rest;@RestController @RequestMapping("/orders") @RequiredArgsConstructor class OrderController { private final PlaceOrderUseCase placeOrder; // depends on the port, not the service
@PostMapping ResponseEntity<OrderResponse> create(@RequestBody PlaceOrderRequest req) { OrderId id = placeOrder.place(req.toCommand()); return ResponseEntity.created(URI.create("/orders/" + id.value())) .body(new OrderResponse(id.value())); } } ```
PlaceOrderRequest is a DTO with Jackson annotations; it never touches the domain.
Step 6 — Outbound adapter (JPA)
```java
package com.acme.orders.infrastructure.persistence;@Component @RequiredArgsConstructor class OrderJpaAdapter implements OrderRepository { private final OrderJpaRepository jpa; private final OrderMapper mapper;
public void save(Order order) { jpa.save(mapper.toEntity(order)); } public Optional<Order> findById(OrderId id) { return jpa.findById(id.value()).map(mapper::toDomain); } }
@Entity @Table(name = "orders") class OrderJpaEntity { /* JPA-only fields and annotations */ } ```
The JPA entity and the domain Order are two different classes joined by a mapper. Yes, it is more code. It is also why your domain doesn't break the day you switch to MongoDB.
Step 7 — Tests that take 5 ms
```java
class PlaceOrderServiceTest {
OrderRepository repo = new InMemoryOrderRepository();
InventoryPort inventory = new FakeInventory();
PlaceOrderService service = new PlaceOrderService(repo, inventory);@Test void places_an_order_and_reserves_inventory() { OrderId id = service.place(new PlaceOrderCommand(customer(), lines()));
assertThat(repo.findById(id)).isPresent(); assertThat(inventory.reservedFor(id)).isNotEmpty(); } } ```
No @SpringBootTest, no Testcontainers, no H2. Tests for business rules belong with the business rules.
Layered vs Hexagonal — head to head
| Concern | Layered | Hexagonal | |---|---|---| | Dependency direction | Top to bottom | Inward only | | Domain testability | Needs Spring | Pure JUnit | | Swap database | Touches services | Replace one adapter | | Onboarding new devs | Familiar | Steeper, then clearer | | Boilerplate | Less | More (mappers) |
Hexagonal pays off in long-lived systems. For a 2-week throwaway script, layered is fine.
Common mistakes
- Letting JPA entities leak — if your controller returns
OrderJpaEntity, you've collapsed the architecture. - Anemic domain — moving all the rules back into the service. The aggregate must enforce its own invariants.
- Too many ports — a port per database table is overkill. One port per *capability* the domain needs.
- Bidirectional dependencies — if the domain ever imports something from
infrastructure, you've broken the inversion.
Production best practices
- Enforce package dependencies with ArchUnit:
domainmay not depend on anything except the JDK. - Use MapStruct for entity ↔ domain mapping to keep the boilerplate honest.
- Keep one
@Configurationper adapter so you can swap them in tests with@Import. - Combine with Domain-Driven Design tactical patterns (aggregates, value objects) for maximum clarity.
FAQ
Isn't this just over-engineering? For a CRUD app, yes. For a system with non-trivial business rules and a 5+ year lifetime, the test speed and swap-ability pay for the extra mapper code many times over.
Where do DTOs live?
Inbound DTOs (PlaceOrderRequest) live with the inbound adapter that uses them. The domain never sees them.
Can I use this with Spring Data JPA? Yes — JPA stays in the persistence adapter. The domain never imports a JPA type.
Related tutorials
Architecture
Hexagonal (Ports & Adapters) Architecture
TL;DR
Key takeaways
- Understand the core concepts behind Hexagonal Architecture with Spring Boot — Build Clean, Maintainable Applications in a production context.
- Apply the patterns to real Software Design & Architecture systems, not just toy examples.
- Recognize the trade-offs, failure modes, and operational concerns before adopting them.
- Get a clear path to the next step — related tutorials, tools, and reference architectures.
Avoid these
Common mistakes
1. Copy-pasting code without understanding the trade-offs
It's tempting to ship a snippet from a blog post into production, but Software Design & Architecture patterns only work when the failure modes are understood. Always reason about timeouts, retries, and consistency.
2. Skipping observability from day one
Structured logs, metrics, and traces are not optional. Wire them in before you ship — debugging Software Design & Architecture systems without them is painful and expensive.
3. Optimizing too early
Premature caching, sharding, or microservice extraction adds operational cost. Validate the bottleneck with real measurements first.
4. Ignoring security defaults
Secrets in env files, open management ports, missing RBAC — these are the most common production incidents. Treat security as part of the definition of done.
Ship it safely
Production best practices
Apply these before promoting Hexagonal Architecture with Spring Boot — Build Clean, Maintainable Applications to a real production environment.
Scalability
Design Software Design & Architecture services to scale horizontally. Keep request handlers stateless, push session and cache state to external stores (Redis, the database), and benchmark p95/p99 latency under realistic load before tuning.
Monitoring & Observability
Emit metrics (RED/USE), structured JSON logs, and distributed traces from day one. Wire dashboards and alerts to SLOs you actually care about — error rate, latency, saturation — not vanity metrics.
Logging
Log with correlation IDs, never log secrets or PII, and centralize logs (ELK, Loki, CloudWatch). Use levels deliberately: INFO for state changes, WARN for recoverable issues, ERROR for incidents.
Security
Apply least-privilege IAM, rotate secrets through a vault, validate every input, and patch dependencies on a schedule. For HTTP services, enable TLS everywhere and set sensible security headers.
Testing
Layer unit, integration, and contract tests. Run them in CI on every PR, and add smoke tests post-deploy. For Software Design & Architecture systems, also run chaos and load tests before a major release.
Reliability & Rollouts
Ship with health checks, readiness probes, graceful shutdown, and a rollback strategy. Prefer canary or blue/green deploys over big-bang releases.
Questions
Frequently asked questions
Is this tutorial up to date?
Yes. This tutorial was last reviewed and updated on May 25, 2026. We revisit popular Software Design & Architecture tutorials regularly to keep them aligned with current best practices.
What level is this tutorial aimed at?
It is written for working developers with some backend experience. Beginners can still follow along, and senior engineers will find production-grade patterns and trade-off discussions.
Do I need to follow every step in order?
The walkthrough is sequential because each step depends on the previous one. If you only need a specific concept, the table of contents at the top of the article lets you jump straight to that section.
Where can I find the source code?
Code samples are inlined in the tutorial. When a companion repository is published it will be linked at the top of this page.
Go deeper
Further reading
More From the Channel
Follow the full tutorial series on YouTube
The MasterLabSystems channel publishes in-depth, project-based tutorials on Java, Spring Boot, microservices, Docker, Kubernetes, AWS and DevOps — the same topics covered on this site, with full code walkthroughs.
Stay in the Loop
Get the next tutorial in your inbox
next tutorial →
Domain-Driven Design (DDD) with Spring Boot — Practical Guide for Real Systems
Related tutorials
Essential Software Design Principles Every Developer Should Know
A practical overview of the design principles that separate junior and senior engineers — DRY, KISS, YAGNI, SOLID, separation of concerns and more.
DRY, KISS, and YAGNI Explained
Three short acronyms that prevent most over-engineering — what DRY, KISS and YAGNI mean and how to apply them without going too far.
Composition Over Inheritance in Java
Why Java developers should prefer composition over inheritance — with side-by-side refactoring examples that show why composition scales better.
