Hexagonal Architecture in Java and Spring: A Comprehensive Guide
Hexagonal Architecture, also known as the Ports and Adapters pattern, was introduced by Alistair Cockburn to solve a common problem in software development: the entanglement of business logic with infrastructure, user interfaces, and external systems.
When your core business rules are tightly coupled with the database or web framework (like Spring MVC), testing becomes difficult, refactoring is risky, and swapping out technologies (e.g., changing from a REST API to a GraphQL API) requires rewriting significant parts of the system.
In this comprehensive guide, we will explore the principles of Hexagonal Architecture, how to implement it using Java and Spring Boot, and how it serves as a powerful foundation for both Microservices and Modular Monolith strategies.
Core Concepts of Hexagonal Architecture
The architecture is visualized as a hexagon, not because the number six has any magical properties, but to illustrate that an application has multiple entry and exit points. The core idea is simple: the inside should not depend on the outside.
There are three main components you need to understand:
- The Domain (The Center): This is where your business logic lives. It contains entities, value objects, and domain services. It is written in pure Java and has zero dependencies on external frameworks, including Spring.
- Ports (The Interfaces): Ports act as the boundary of your application. They are abstractions (typically Java
interfaces) that define how the outside world can interact with the core, and how the core can interact with the outside world.- Inbound Ports (Primary Ports): Define the use cases of the application (e.g.,
CreateUserUseCase). They are called by the outside world. - Outbound Ports (Secondary Ports): Define the services the core needs from the outside world (e.g.,
UserRepository). They are called by the core.
- Inbound Ports (Primary Ports): Define the use cases of the application (e.g.,
- Adapters (The Implementation): Adapters belong to the infrastructure layer. They adapt external technologies to the generic ports defined by the application.
- Primary Adapters (Driving Adapters): They drive the application. Examples include Spring REST Controllers, CLI runners, or message listeners (Kafka, RabbitMQ) that invoke the Inbound Ports.
- Secondary Adapters (Driven Adapters): They are driven by the application. Examples include Spring Data JPA repositories, external HTTP clients, or email senders that implement the Outbound Ports.
Implementing Hexagonal Architecture in Spring Boot
Let’s look at a practical example: a simple system to manage bank accounts.
1. The Domain Layer
The domain must be pure. No @Entity, no @Table, no Spring annotations.
// Domain Entity
public class BankAccount {
private final Long id;
private BigDecimal balance;
public BankAccount(Long id, BigDecimal balance) {
this.id = id;
this.balance = balance;
}
public void deposit(BigDecimal amount) {
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Deposit must be positive");
}
this.balance = this.balance.add(amount);
}
public void withdraw(BigDecimal amount) {
if (this.balance.compareTo(amount) < 0) {
throw new IllegalStateException("Insufficient funds");
}
this.balance = this.balance.subtract(amount);
}
// Getters
}
2. The Application Layer (Ports and Use Cases)
This layer orchestrates domain objects and defines the ports.
The Outbound Port:
// Outbound Port (driven by the core)
public interface BankAccountRepositoryPort {
Optional<BankAccount> findById(Long id);
BankAccount save(BankAccount account);
}
The Inbound Port:
// Inbound Port (drives the core)
public interface DepositMoneyUseCase {
void execute(Long accountId, BigDecimal amount);
}
The Application Service (implements the inbound port):
// Pure Java service, no Spring annotations (we'll wire it later)
public class DepositMoneyService implements DepositMoneyUseCase {
private final BankAccountRepositoryPort repositoryPort;
public DepositMoneyService(BankAccountRepositoryPort repositoryPort) {
this.repositoryPort = repositoryPort;
}
@Override
public void execute(Long accountId, BigDecimal amount) {
BankAccount account = repositoryPort.findById(accountId)
.orElseThrow(() -> new IllegalArgumentException("Account not found"));
account.deposit(amount);
repositoryPort.save(account);
}
}
3. The Infrastructure Layer (Adapters)
This is where Spring Boot and external dependencies live.
Secondary Adapter (Database):
@Component
public class JpaBankAccountAdapter implements BankAccountRepositoryPort {
private final SpringDataBankAccountRepository jpaRepository;
public JpaBankAccountAdapter(SpringDataBankAccountRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Optional<BankAccount> findById(Long id) {
return jpaRepository.findById(id).map(this::toDomainEntity);
}
@Override
public BankAccount save(BankAccount account) {
BankAccountEntity entity = toJpaEntity(account);
BankAccountEntity saved = jpaRepository.save(entity);
return toDomainEntity(saved);
}
// Mapping logic omitted for brevity
}
// Spring Data JPA interface
interface SpringDataBankAccountRepository extends JpaRepository<BankAccountEntity, Long> {}
Primary Adapter (REST Controller):
@RestController
@RequestMapping("/accounts")
public class BankAccountController {
// Controller depends only on the Inbound Port
private final DepositMoneyUseCase depositMoneyUseCase;
public BankAccountController(DepositMoneyUseCase depositMoneyUseCase) {
this.depositMoneyUseCase = depositMoneyUseCase;
}
@PostMapping("/{id}/deposit")
public ResponseEntity<Void> deposit(@PathVariable Long id, @RequestBody DepositRequest request) {
depositMoneyUseCase.execute(id, request.getAmount());
return ResponseEntity.ok().build();
}
}
4. Wiring it all together
Since DepositMoneyService has no Spring annotations to keep it pure, we configure it manually:
@Configuration
public class DomainConfiguration {
@Bean
public DepositMoneyUseCase depositMoneyUseCase(BankAccountRepositoryPort repositoryPort) {
return new DepositMoneyService(repositoryPort);
}
}
5. Recommended Package Structure
The package layout should make the architectural boundaries visible at a glance. A widely adopted convention is to mirror the three layers directly in the package names:
com/example/bankaccount/
├── domain/
│ └── model/
│ └── BankAccount.java
├── application/
│ ├── port/
│ │ ├── in/
│ │ │ └── DepositMoneyUseCase.java
│ │ └── out/
│ │ └── BankAccountRepositoryPort.java
│ └── service/
│ └── DepositMoneyService.java
└── infrastructure/
├── adapter/
│ ├── in/
│ │ └── rest/
│ │ └── BankAccountController.java
│ └── out/
│ └── persistence/
│ ├── JpaBankAccountAdapter.java
│ ├── BankAccountEntity.java
│ └── SpringDataBankAccountRepository.java
└── config/
└── DomainConfiguration.java
The dependency rule in one sentence: domain knows nothing, application knows only domain, and infrastructure knows everything but is depended on by nothing.
Testing in Isolation: The Main Payoff
Testability is the most immediate, concrete benefit of this architecture. Because DepositMoneyService depends only on the BankAccountRepositoryPort interface — not on Spring Data, JPA, or any database driver — you can test your entire business logic with a plain JUnit 5 test and a Mockito mock. No @SpringBootTest, no embedded database, no network. The test runs in milliseconds.
class DepositMoneyServiceTest {
private BankAccountRepositoryPort repositoryPort;
private DepositMoneyService service;
@BeforeEach
void setUp() {
repositoryPort = mock(BankAccountRepositoryPort.class);
service = new DepositMoneyService(repositoryPort);
}
@Test
void shouldDepositMoneySuccessfully() {
BankAccount account = new BankAccount(1L, new BigDecimal("100.00"));
when(repositoryPort.findById(1L)).thenReturn(Optional.of(account));
when(repositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0));
service.execute(1L, new BigDecimal("50.00"));
ArgumentCaptor<BankAccount> captor = ArgumentCaptor.forClass(BankAccount.class);
verify(repositoryPort).save(captor.capture());
assertEquals(new BigDecimal("150.00"), captor.getValue().getBalance());
}
@Test
void shouldThrowWhenAccountNotFound() {
when(repositoryPort.findById(anyLong())).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class,
() -> service.execute(999L, new BigDecimal("50.00")));
}
}
This test is fast, deterministic, and tests exactly the business rule in complete isolation. The JPA adapter, the REST controller, and the Spring context are completely irrelevant to verifying that deposit() adds the correct amount.
Hexagonal Architecture in Microservices
Hexagonal architecture is incredibly well-suited for Microservices. In a distributed environment, services communicate via various protocols (REST, gRPC, RabbitMQ, Kafka).
If you tightly couple your business logic to a specific protocol (e.g., expecting an HttpServletRequest in your service layer), changing it becomes a nightmare.
With Hexagonal Architecture:
- Your core domain logic remains oblivious to whether the request came from an HTTP call, an asynchronous Kafka event, or a scheduled Cron job.
- You can easily mount multiple Primary Adapters to the same Inbound Port (e.g., both a REST API adapter and a Kafka Listener adapter invoking
DepositMoneyUseCase). - Testability skyrockets. You can test your core microservice logic by injecting mock Outbound Ports, completely avoiding the need to spin up test databases or mock HTTP servers during unit tests.
The following example illustrates how effortlessly you can add a Kafka consumer as a second primary adapter. The domain and the application service are completely unchanged:
@Component
public class KafkaDepositAdapter {
private final DepositMoneyUseCase depositMoneyUseCase;
public KafkaDepositAdapter(DepositMoneyUseCase depositMoneyUseCase) {
this.depositMoneyUseCase = depositMoneyUseCase;
}
@KafkaListener(topics = "deposit-requests", groupId = "bank-service")
public void onDepositRequest(DepositRequestEvent event) {
depositMoneyUseCase.execute(event.getAccountId(), event.getAmount());
}
}
DepositMoneyService does not know — or care — whether the trigger came from an HTTP call or a Kafka event. You can add a gRPC adapter, a CLI runner, or a scheduled job the same way, with zero impact on the domain or its tests.
A Powerful Strategy for the Modular Monolith
While Microservices solve organizational scalability challenges, they introduce significant operational complexity (distributed transactions, network latency, complex deployments). A modern alternative is the Modular Monolith.
A Modular Monolith is a single deployable application divided into strictly encapsulated business modules (e.g., Billing, Inventory, Shipping).
Hexagonal Architecture is the secret weapon for preventing a Modular Monolith from degrading into a “Big Ball of Mud”:
- Modules as Hexagons: Each module is structured as its own independent hexagon.
- Strict Boundaries: Modules communicate with each other exclusively through their Input Ports. The
Billingmodule cannot directly query theInventorydatabase. It must call theInventorymodule’s exposed use case interfaces. - Internal Adaptability: The internal details of a module (whether it uses JPA or JDBC, relational or NoSQL) are completely hidden and handled by internal Secondary Adapters.
- The Migration Path: The biggest advantage is that if a specific module (say,
Shipping) requires independent scaling and needs to be extracted into a Microservice, the migration path is trivial. You keep the internal Domain and Ports exactly the same, and simply replace the internal component-to-component adapter with a network adapter (REST/gRPC).
Conclusion
Hexagonal Architecture requires more upfront structure than a classic layered approach: you define port interfaces, write mappers between domain objects and JPA entities, and wire beans manually. For a simple CRUD endpoint this can feel verbose.
The payoff becomes clear the moment you face a real change: swapping the database means only replacing one adapter; adding a Kafka entry point means writing one new class; testing business logic means writing a plain JUnit test with no application context. These are not hypothetical benefits — the code examples in this guide demonstrate all three.
By keeping your core business logic pure and isolating all infrastructure concerns behind adapters, you build software that is robust, independently testable, and framework-agnostic — whether it lives in a tight Modular Monolith today or gets extracted into an independent Microservice tomorrow.