Hexagonal Architecture with Spring Boot: Ports, Adapters, and Testability at Scale
Most Spring Boot applications start clean and become tangled messes within 18 months. Business logic bleeds into REST controllers; JPA entities carry business rules; tests require a running database. Hexagonal Architecture (Ports and Adapters) is the structural antidote — protecting your domain logic from framework coupling and enabling true unit testability of business rules.
Table of Contents
- The Architecture Rot Problem in Spring Boot Services
- Hexagonal Architecture: Core Concepts
- Project Structure and Package Design
- The Domain Core: Pure Business Logic
- Ports: The Interface Boundary
- Adapters: Technology-Specific Implementations
- Testing Strategy: Unit, Integration, and System
- Common Failures and Anti-Patterns
- Trade-offs and When NOT to Use Hexagonal Architecture
- Key Takeaways
1. The Architecture Rot Problem in Spring Boot Services
A standard "layered" Spring Boot application has controllers calling services calling repositories. This works for 6 months. Then: a business rule gets added to a controller because it needs the HTTP request; a JPA entity gets a @Transient method that implements pricing logic; a repository interface gets a 400-line custom JPQL query that embeds business filtering.
The result: you can't test the pricing logic without loading the Spring context. You can't swap from PostgreSQL to MongoDB without rewriting business rules. You can't run a Kafka consumer using the same business logic as the REST endpoint without duplicating code.
@Service class that injected a JPA repository and a Spring Security context — both unavailable in the Kafka consumer. They duplicated the business logic into a "Kafka service." Six months later, both diverged. Two bugs were fixed in the REST path but not the Kafka path. A compliance audit found the discrepancy.
2. Hexagonal Architecture: Core Concepts
Hexagonal Architecture, coined by Alistair Cockburn in 2005 and popularized by DDD practitioners, organizes code around the business domain — not the technology. The central idea: the domain logic (the "hexagon") knows nothing about how it's called or what it calls.
[REST Adapter] [Kafka Adapter] [CLI Adapter]
↓ uses ↓ uses
[Inbound Port / Use Case Interface]
↓ implemented by
┌────────────────────────────┐
│ DOMAIN CORE (Hexagon) │
│ Entities, Domain Services │
│ Business Rules & Logic │
└────────────────────────────┘
↑ uses (via interface)
[Outbound Port / Repository Interface]
↑ implemented by
[JPA Adapter] [HTTP Client Adapter] [In-Memory Adapter]
DRIVEN SIDE (Secondary)
Two sides, two port directions:
- Inbound Ports (Primary/Driving): Interfaces that define what the application can do — use case interfaces. Driving adapters (REST controllers, Kafka consumers, CLI commands) call these interfaces.
- Outbound Ports (Secondary/Driven): Interfaces that define what the application needs — repository interfaces, notification interfaces, external API interfaces. The domain defines these; adapters implement them.
3. Project Structure and Package Design
com.myapp.payments
├── domain/ # Domain core (no Spring, no JPA)
│ ├── model/
│ │ ├── Payment.java # Domain entity
│ │ ├── PaymentId.java # Value object
│ │ └── PaymentStatus.java # Domain enum
│ ├── service/
│ │ └── PaymentDomainService.java # Business logic
│ └── exception/
│ └── InsufficientFundsException.java
│
├── application/ # Use cases (orchestration layer)
│ ├── port/
│ │ ├── in/ # Inbound ports (use case interfaces)
│ │ │ ├── ProcessPaymentUseCase.java
│ │ │ └── GetPaymentStatusUseCase.java
│ │ └── out/ # Outbound ports
│ │ ├── PaymentRepository.java
│ │ ├── NotificationPort.java
│ │ └── FraudCheckPort.java
│ └── service/
│ └── PaymentApplicationService.java # Implements use cases
│
└── adapter/ # All technology-specific code
├── in/
│ ├── rest/ # REST driving adapter
│ │ ├── PaymentController.java
│ │ └── dto/
│ └── kafka/ # Kafka driving adapter
│ └── PaymentEventConsumer.java
└── out/
├── persistence/ # JPA driven adapter
│ ├── PaymentJpaRepository.java
│ └── PaymentPersistenceAdapter.java
├── notification/ # Email/SMS driven adapter
└── fraud/ # External fraud API driven adapter
4. The Domain Core: Pure Business Logic
The domain core has zero Spring annotations, zero JPA annotations, zero framework dependencies. It's plain Java — testable with pure unit tests, runnable without a Spring context:
// Domain entity — no JPA, no Spring
public class Payment {
private final PaymentId id;
private final Money amount;
private final AccountId senderId;
private final AccountId receiverId;
private PaymentStatus status;
// Factory method enforces business invariants
public static Payment create(Money amount, AccountId from, AccountId to) {
if (amount.isNegativeOrZero()) {
throw new InvalidPaymentAmountException(amount);
}
if (from.equals(to)) {
throw new SelfTransferException(from);
}
return new Payment(PaymentId.generate(), amount, from, to,
PaymentStatus.PENDING);
}
public void approve() {
if (this.status != PaymentStatus.PENDING) {
throw new InvalidStateTransitionException(status, PaymentStatus.APPROVED);
}
this.status = PaymentStatus.APPROVED;
}
}
5. Ports: The Interface Boundary
// Inbound port — defines what the application can do
public interface ProcessPaymentUseCase {
PaymentResult process(ProcessPaymentCommand command);
}
// Command object — value object, no Spring annotations
public record ProcessPaymentCommand(
String senderId,
String receiverId,
BigDecimal amount,
String currency
) {}
// Outbound port — defines what the application needs
// (Note: no JPA, no Spring, just a domain interface)
public interface PaymentRepository {
void save(Payment payment);
Optional<Payment> findById(PaymentId id);
List<Payment> findPendingOlderThan(Duration age);
}
// Outbound port for fraud checking
public interface FraudCheckPort {
FraudCheckResult check(PaymentId paymentId, Money amount, AccountId sender);
}
6. Adapters: Technology-Specific Implementations
// REST driving adapter — Spring annotations live here, not in domain
@RestController
@RequestMapping("/api/payments")
@RequiredArgsConstructor
public class PaymentController {
private final ProcessPaymentUseCase processPayment; // Inbound port
@PostMapping
public ResponseEntity<PaymentResponse> createPayment(
@RequestBody @Valid CreatePaymentRequest request) {
ProcessPaymentCommand command = new ProcessPaymentCommand(
request.senderId(), request.receiverId(),
request.amount(), request.currency()
);
PaymentResult result = processPayment.process(command);
return ResponseEntity.ok(PaymentResponse.from(result));
}
}
// JPA driven adapter — implements outbound port
@Component
@RequiredArgsConstructor
class PaymentPersistenceAdapter implements PaymentRepository {
private final PaymentJpaRepository jpaRepo;
private final PaymentMapper mapper;
@Override
public void save(Payment payment) {
jpaRepo.save(mapper.toJpaEntity(payment));
}
@Override
public Optional<Payment> findById(PaymentId id) {
return jpaRepo.findById(id.value())
.map(mapper::toDomain);
}
}
Key pattern: The mapper converts between the domain Payment object and the JPA entity PaymentJpaEntity. These are separate classes — the JPA entity has @Entity, @Id, column mappings; the domain entity has business invariants. Never merge them.
7. Testing Strategy: Unit, Integration, and System
Domain Unit Tests (Fast, No Spring)
Test all business rules with pure Java unit tests. No mocking framework needed — just create domain objects directly. These tests run in milliseconds.
class PaymentTest {
@Test
void shouldRejectNegativeAmount() {
assertThrows(InvalidPaymentAmountException.class, () ->
Payment.create(Money.of(-10, "USD"), AccountId.of("A"), AccountId.of("B"))
);
}
@Test
void shouldRejectSelfTransfer() {
assertThrows(SelfTransferException.class, () ->
Payment.create(Money.of(100, "USD"), AccountId.of("A"), AccountId.of("A"))
);
}
}
Application Service Tests (Mock Outbound Ports)
Test use case orchestration by mocking the outbound port interfaces. No JPA, no HTTP — just verify that the right methods are called with the right arguments:
@ExtendWith(MockitoExtension.class)
class PaymentApplicationServiceTest {
@Mock PaymentRepository repository;
@Mock FraudCheckPort fraudCheck;
@Mock NotificationPort notifications;
@InjectMocks PaymentApplicationService service;
@Test
void shouldSavePaymentAfterFraudCheckPasses() {
when(fraudCheck.check(any(), any(), any()))
.thenReturn(FraudCheckResult.SAFE);
service.process(new ProcessPaymentCommand("A", "B", BigDecimal.TEN, "USD"));
verify(repository).save(argThat(p -> p.getStatus() == PaymentStatus.APPROVED));
verify(notifications).notifyPaymentInitiated(any());
}
}
8. Common Failures and Anti-Patterns
- JPA entity leaking into domain: When you return
PaymentJpaEntityfromPaymentRepositoryinstead ofPayment, you've broken the port — the domain now depends on JPA. Always map at the adapter boundary. - Use case interface per method: Creating 40 single-method use case interfaces for 40 service methods generates unmaintainable ceremony. Group related operations:
ProcessPaymentUseCasewith multiple methods, or use Command objects with a single dispatcher. - Domain services calling Spring beans: Injecting
ApplicationContextor any Spring bean into a domain service defeats the isolation. If you need to publish an event, use an outbound port interface (EventPublisherPort). - Over-engineering simple services: A CRUD microservice with 2 tables and no business rules doesn't benefit from hexagonal architecture. The overhead of adapters, mappers, and port interfaces exceeds the value.
9. Trade-offs and When NOT to Use Hexagonal Architecture
- High initial complexity: For a 3-developer team building an MVP, the ceremony of ports, adapters, and mappers slows initial delivery. Apply hexagonal architecture to services that are expected to live long-term with complex business logic.
- Mapper proliferation: Every entity needs domain ↔ JPA ↔ DTO mappers. With MapStruct, this is manageable but still adds maintenance surface area.
- Use when: Multiple input channels (REST + Kafka + gRPC + CLI), complex business rules that need isolated testing, anticipated database technology changes, or high test coverage requirements for regulated industries (banking, healthcare).
- Don't use when: Simple CRUD services, internal tooling with short lifespans, or solo developer projects where the ceremony outweighs the structural benefit.
10. Key Takeaways
- The domain core contains zero Spring/JPA dependencies — it's pure Java testable with no Spring context.
- Inbound ports define what the application does (use case interfaces); outbound ports define what it needs (repository, notification interfaces).
- All Spring annotations live in adapters — never in domain or application layers.
- Map aggressively at adapter boundaries: domain ↔ JPA entity, domain ↔ DTO. Use MapStruct to eliminate boilerplate.
- The primary testability benefit: use case tests run with mocked ports in milliseconds, no Spring context needed.
- Apply hexagonal architecture to services with complex business logic and multiple input channels; avoid for simple CRUD services.
Conclusion
Hexagonal Architecture isn't about following a pattern for its own sake. It's about solving the specific problem of framework coupling that makes Spring Boot services hard to test, hard to evolve, and hard to reuse across different transport channels.
When applied to the right services — those with real domain complexity — the payoff is substantial: domain tests that run in milliseconds, technology swaps that don't touch business logic, and business rules that work identically whether called via REST, Kafka, or gRPC.
Related Posts
Software Engineer · Java · Spring Boot · Architecture · Microservices
Discussion / Comments
Join the conversation — your comment goes directly to my inbox.