Implementing Hexagonal Architecture with Spring Boot
The Promise
Hexagonal Architecture -- also known as Ports & Adapters -- is one of the most discussed architectural patterns of recent years. The core idea: business logic forms the heart of the application. Everything else -- databases, HTTP interfaces, messaging -- connects as adapters to defined ports. The core doesn't know the outside world. The outside world only knows the core through its ports.
The promise is tempting: swappable infrastructure, testable business logic, clear dependency direction. In theory, it sounds like the perfect architecture. In practice, we've seen teams -- ourselves included -- regularly fall into over-engineering when implementing it.
What Hexagonal Architecture Actually Means
Before we get to the pragmatic implementation, let's cover the basics.
Ports are interfaces defined by the application core. There are inbound ports (use cases that are called from the outside) and outbound ports (dependencies that the core needs from the outside world). An inbound port could be an interface like CreateOrderUseCase. An outbound port could be an interface like OrderRepository.
Adapters are the concrete implementations of these ports. A REST controller is an inbound adapter -- it receives HTTP requests and invokes the corresponding use case. A JPA repository implementation is an outbound adapter -- it implements the port with a specific database technology.
The core contains the domain logic and application logic. It defines the ports but knows nothing about adapters. Dependencies always point inward.
So much for the textbook version. The problem begins when you translate this model literally into a Spring Boot project.
Where Textbook Purity Falls Short
In an earlier project, we implemented hexagonal architecture strictly. Every database connection behind an interface. Every domain class free from framework annotations. Mapping layers between domain model, persistence model, and API model. The result: for a simple CRUD operation, we had to touch seven classes. A new field in the domain model triggered changes in five files.
The problems in detail:
Too many interfaces with a single implementation. We had dozens of interfaces, each with exactly one class behind it. That's not an abstraction gain -- that's noise. An interface that never gets a second implementation makes code navigation harder and doubles the effort for changes.
Mapping orgies between layers. Domain model to persistence model. Persistence model to domain model. Domain model to DTO. DTO to response. Four mapping steps for a single database access. The mappers themselves became a source of bugs because forgotten fields led to subtle errors.
Framework hostility. Spring Boot is not an implementation detail that needs to be hidden. The probability of replacing Spring Boot with another framework in a running project is zero. Yet we invested effort to keep every Spring annotation out of the core. That effort would have been better spent on features.
The Pragmatic Version
After these experiences, we revised our approach. We adopt the valuable ideas of hexagonal architecture -- dependency direction, separation of business logic and infrastructure, testability -- without the academic purity.
Package by Feature
The most important structural decision: we organize our code by business features, not by technical layers. A feature package contains everything related to a specific business domain.
com.encircle360.invoiceservice
├── invoice/
│ ├── Invoice.java // Domain model (JPA Entity)
│ ├── InvoiceService.java // Application logic
│ ├── InvoiceRepository.java // Spring Data Interface
│ ├── InvoiceController.java // REST Adapter
│ ├── InvoiceCreatedEvent.java // Domain Event
│ └── InvoiceTestFactory.java // Test helpers
├── payment/
│ ├── Payment.java
│ ├── PaymentService.java
│ ├── PaymentGateway.java // Interface (multiple implementations)
│ ├── StripePaymentGateway.java // Adapter: Stripe
│ ├── PayPalPaymentGateway.java // Adapter: PayPal
│ └── PaymentController.java
└── config/
└── SecurityConfig.java
What stands out here: InvoiceRepository is directly a Spring Data interface. No additional port interface in front of it. Invoice serves as both domain model and JPA entity. And PaymentGateway has an interface -- because there are actually multiple implementations.
Interfaces Only When Needed
The rule is simple: an interface is created when there are multiple implementations or when a dependency needs to be replaced for testing with a different implementation that can't be covered with the available tools (Testcontainers, @MockBean).
In the case of PaymentGateway: Stripe and PayPal are two different payment providers. An interface makes sense here -- the application logic shouldn't know which provider is currently active.
InvoiceRepository, on the other hand, doesn't need an additional interface. Spring Data generates the implementation, and with Testcontainers we test against a real database. An extra interface would only add an indirection that nobody needs.
Use Spring Deliberately, Don't Hide It
In our pragmatic approach, Spring is not a foreign body that must be kept out of the core. @Service, @Transactional, @Entity -- these annotations are part of everyday work and make the code more readable, not worse.
What we still pay attention to: controllers are thin. They receive requests, validate inputs, and delegate to the service. Business logic has no place in controllers. And the business logic itself lives as much as possible in the domain objects, not in anemic services.
@Entity
public class Invoice {
// ... Fields and JPA mapping
public void markAsPaid(LocalDate paymentDate) {
if (this.status == InvoiceStatus.CANCELLED) {
throw new InvoiceAlreadyCancelledException(this.id);
}
this.status = InvoiceStatus.PAID;
this.paidAt = paymentDate;
}
public boolean isOverdue() {
return this.status == InvoiceStatus.OPEN
&& this.dueDate.isBefore(LocalDate.now());
}
}
The business rules -- when an invoice can be marked as paid, when it is overdue -- live in the domain object. The service orchestrates, the domain object decides.
Integration Tests Instead of Mock Acrobatics
The pragmatic architecture pays off especially when testing. Instead of testing each layer in isolation with mocks, we rely on integration tests that verify the entire stack: from the HTTP request through the business logic to the database.
@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
class InvoiceControllerIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:12");
@Autowired
private MockMvc mockMvc;
@Test
void shouldCreateInvoiceAndPersist() throws Exception {
mockMvc.perform(post("/api/invoices")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"customer\": \"ACME\", \"amount\": 1500.00}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").exists())
.andExpect(jsonPath("$.status").value("OPEN"));
}
}
A single test that covers the entire path. When this test passes, the chain works: the controller accepts the request, the service processes it, the repository writes to the database, and the response comes back correctly. No mock providing a false sense of security. Testcontainers starts a real PostgreSQL instance, and the test runs in seconds.
Unit tests still have their place -- for complex domain logic like the markAsPaid method above. But at the layer boundaries, where the integration of components needs to be tested, integration tests with Testcontainers are superior.
Honest Trade-offs
This approach is no silver bullet. There are trade-offs you should be aware of.
The coupling to Spring and JPA is real. If you actually need to switch frameworks, the effort is higher than with pure hexagonal architecture. Our experience: in over five years of Spring Boot projects, this has never happened once.
The line between pragmatism and sloppiness is blurry. "We don't need an interface" can quickly become an excuse to skip abstraction entirely. The rule of thumb helps: if there's a concrete reason for an interface, create it. If the only reason is "it might be needed someday," leave it out.
Domain logic in JPA entities works well as long as the entities don't become too complex. For very rich domain models, it may make sense to introduce a separate domain model. But that's a decision you make when the complexity demands it -- not preemptively.
Conclusion
Hexagonal architecture contains valuable ideas: dependencies point inward, business logic is independent of infrastructure, the application is testable. We adopt these principles. The academic implementation with interfaces for everything, mapping between five layers, and framework abstinence in the core -- we leave that out.
Package by feature, interfaces only when needed, Spring as a tool rather than an enemy, integration tests with Testcontainers -- that's our pragmatic interpretation. It delivers most of the benefits of hexagonal architecture at a fraction of the complexity. Not perfect in theory, but effective in practice.
Written by
Patrick HütterFounder & Software Architect
Software architect, engineer and entrepreneur. Patrick has been building products and platforms for over a decade — from enterprise backends and cloud-native infrastructure to AI-powered applications. As founder of encircle360, he combines deep technical expertise with entrepreneurial vision, driving open source projects that create real impact.
You might also like
From Manifest to Production: How ADL, A2A and the Inference Gateway Are Revolutionizing Agent Infrastructure
Jul 5, 2026 · 12 min read
Agent Orchestration with Java: Bringing LLM Agents to Production on the JVM
Jul 4, 2026 · 6 min read
Spring AI: How Java Developers Can Finally Integrate AI Features the Right Way
Mar 25, 2026 · 5 min read