Hexagonal architecture ports adapters spring boot testable design
Software Dev March 19, 2026 20 min read Software Architecture Series

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

  1. The Architecture Rot Problem in Spring Boot Services
  2. Hexagonal Architecture: Core Concepts
  3. Project Structure and Package Design
  4. The Domain Core: Pure Business Logic
  5. Ports: The Interface Boundary
  6. Adapters: Technology-Specific Implementations
  7. Testing Strategy: Unit, Integration, and System
  8. Common Failures and Anti-Patterns
  9. Trade-offs and When NOT to Use Hexagonal Architecture
  10. 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.

Real scenario: A fintech team needed to expose their payment processing logic via both a REST API and a Kafka consumer. The business logic was in a @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.

                    DRIVING SIDE (Primary)
            [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:

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

9. Trade-offs and When NOT to Use Hexagonal Architecture

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

Md Sanwar Hossain
Md Sanwar Hossain

Software Engineer · Java · Spring Boot · Architecture · Microservices

Discussion / Comments

Join the conversation — your comment goes directly to my inbox.

Back to Blog