Hexagonal / Onion Architecture in a Real Java Codebase: Migration Strategies
Refactoring a legacy Java application to use Hexagonal Architecture—also known as Ports and Adapters or Onion Architecture—sounds appealing in theory. Clean separation of concerns, testable business logic, and independence from frameworks all promise a more maintainable codebase. But the reality of migrating an existing application is messy, politically fraught, and full of compromises.
Understanding the Architecture
Before diving into migration strategies, let’s establish what Hexagonal Architecture actually means in practice. The core idea is organizing your code in concentric layers where dependencies always point inward. Your business logic sits at the center, completely ignorant of databases, web frameworks, or external APIs. These infrastructure concerns live at the outer edges, plugged in through interfaces defined by the core.
In a typical Spring Boot application, you might have controllers that call services that call repositories. Everything knows about everything else. A UserService depends on UserRepository, which is a JPA interface. Your business logic is tightly coupled to Spring Data and Hibernate. Testing requires spinning up a database or mocking implementation details.
Hexagonal Architecture inverts these dependencies. Your domain defines what it needs through ports—interfaces that declare “I need to persist users” or “I need to send notifications”—without specifying how. Adapters implement these ports, translating between your domain’s language and the messy reality of SQL databases or REST APIs.
A Concrete Example: The Legacy Starting Point
Let’s examine a real scenario. You have an e-commerce application with a checkout process. The current implementation looks something like this:
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@Autowired
private OrderService orderService;
@PostMapping
public ResponseEntity<OrderResponse> createOrder(@RequestBody OrderRequest request) {
Order order = orderService.createOrder(request);
return ResponseEntity.ok(new OrderResponse(order));
}
}
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private PaymentServiceClient paymentClient;
@Autowired
private InventoryRepository inventoryRepository;
@Autowired
private EmailService emailService;
@Transactional
public Order createOrder(OrderRequest request) {
// Validate inventory
for (OrderItem item : request.getItems()) {
Inventory inventory = inventoryRepository.findByProductId(item.getProductId());
if (inventory.getQuantity() < item.getQuantity()) {
throw new InsufficientInventoryException();
}
}
// Process payment
PaymentResponse payment = paymentClient.processPayment(
request.getPaymentDetails()
);
// Create order
Order order = new Order();
order.setCustomerId(request.getCustomerId());
order.setItems(request.getItems());
order.setPaymentId(payment.getId());
order.setStatus(OrderStatus.CONFIRMED);
Order savedOrder = orderRepository.save(order);
// Update inventory
for (OrderItem item : request.getItems()) {
inventoryRepository.decrementQuantity(
item.getProductId(),
item.getQuantity()
);
}
// Send confirmation email
emailService.sendOrderConfirmation(savedOrder);
return savedOrder;
}
}
This code works, but it has problems. The business logic is entangled with infrastructure concerns. Testing requires mocking four different dependencies. The service knows about HTTP clients, JPA repositories, and email implementations. If you want to change from REST to gRPC, or swap email providers, you’re modifying business logic.
Strategy One: The Strangler Fig Pattern
The safest migration approach involves gradually wrapping existing functionality rather than rewriting everything at once. You create new hexagonal components alongside the old code, slowly migrating features one at a time. This is the Strangler Fig pattern—new growth eventually replaces the old tree.
Start by identifying a bounded context or feature that’s relatively independent. In our e-commerce example, order creation is a good candidate. Create the domain model first:
package com.example.order.domain;
public class Order {
private final OrderId id;
private final CustomerId customerId;
private final List<OrderLine> lines;
private OrderStatus status;
private PaymentId paymentId;
public Order(CustomerId customerId, List<OrderLine> lines) {
this.id = OrderId.generate();
this.customerId = customerId;
this.lines = new ArrayList<>(lines);
this.status = OrderStatus.PENDING;
validateOrder();
}
private void validateOrder() {
if (lines.isEmpty()) {
throw new EmptyOrderException("Order must contain at least one item");
}
// Business rules in the domain
}
public void confirm(PaymentId paymentId) {
if (this.status != OrderStatus.PENDING) {
throw new InvalidOrderStateException("Can only confirm pending orders");
}
this.paymentId = paymentId;
this.status = OrderStatus.CONFIRMED;
}
// Getters only, no setters - immutability where possible
}
public class OrderLine {
private final ProductId productId;
private final Quantity quantity;
private final Money price;
public OrderLine(ProductId productId, Quantity quantity, Money price) {
this.productId = productId;
this.quantity = quantity;
this.price = price;
}
public Money totalPrice() {
return price.multiply(quantity.value());
}
}
Notice the domain model uses value objects like OrderId, CustomerId, and Money instead of primitives. This prevents invalid state and makes the code more expressive. The domain also contains business rules—you can’t confirm an order that isn’t pending.
Next, define the ports—interfaces that declare what the domain needs:
package com.example.order.domain.port;
public interface OrderRepository {
Order save(Order order);
Optional<Order> findById(OrderId id);
}
public interface InventoryService {
boolean checkAvailability(ProductId productId, Quantity quantity);
void reserve(ProductId productId, Quantity quantity);
}
public interface PaymentService {
PaymentResult processPayment(CustomerId customerId, Money amount, PaymentDetails details);
}
public interface NotificationService {
void notifyOrderConfirmed(Order order);
}
These interfaces belong to the domain layer. They describe what the domain needs using domain language—no mention of databases, HTTP, or email protocols. Now implement the use case:
package com.example.order.application;
public class CreateOrderUseCase {
private final OrderRepository orderRepository;
private final InventoryService inventoryService;
private final PaymentService paymentService;
private final NotificationService notificationService;
public CreateOrderUseCase(
OrderRepository orderRepository,
InventoryService inventoryService,
PaymentService paymentService,
NotificationService notificationService
) {
this.orderRepository = orderRepository;
this.inventoryService = inventoryService;
this.paymentService = paymentService;
this.notificationService = notificationService;
}
public OrderId execute(CreateOrderCommand command) {
// Check inventory availability
for (OrderLineRequest lineRequest : command.getLines()) {
if (!inventoryService.checkAvailability(
lineRequest.getProductId(),
lineRequest.getQuantity()
)) {
throw new InsufficientInventoryException(lineRequest.getProductId());
}
}
// Create domain order
Order order = new Order(
command.getCustomerId(),
command.getLines().stream()
.map(this::toOrderLine)
.collect(Collectors.toList())
);
// Process payment
PaymentResult payment = paymentService.processPayment(
command.getCustomerId(),
order.totalAmount(),
command.getPaymentDetails()
);
if (!payment.isSuccessful()) {
throw new PaymentFailedException(payment.getErrorMessage());
}
// Confirm order with payment
order.confirm(payment.getPaymentId());
// Reserve inventory
for (OrderLine line : order.getLines()) {
inventoryService.reserve(line.getProductId(), line.getQuantity());
}
// Persist order
Order savedOrder = orderRepository.save(order);
// Send notification
notificationService.notifyOrderConfirmed(savedOrder);
return savedOrder.getId();
}
private OrderLine toOrderLine(OrderLineRequest request) {
return new OrderLine(
request.getProductId(),
request.getQuantity(),
request.getPrice()
);
}
}
The use case orchestrates the workflow using only domain concepts and port interfaces. It has no idea how orders are persisted or how payments are processed. Now implement the adapters:
package com.example.order.adapter.persistence;
@Repository
public class JpaOrderRepository implements OrderRepository {
@Autowired
private SpringDataOrderRepository springRepository;
@Autowired
private OrderMapper mapper;
@Override
public Order save(Order order) {
OrderEntity entity = mapper.toEntity(order);
OrderEntity saved = springRepository.save(entity);
return mapper.toDomain(saved);
}
@Override
public Optional<Order> findById(OrderId id) {
return springRepository.findById(id.getValue())
.map(mapper::toDomain);
}
}
// JPA entity - infrastructure concern
@Entity
@Table(name = "orders")
class OrderEntity {
@Id
private String id;
@Column(name = "customer_id")
private String customerId;
@Enumerated(EnumType.STRING)
private OrderStatus status;
@OneToMany(cascade = CascadeType.ALL, mappedBy = "order")
private List<OrderLineEntity> lines;
// JPA requires default constructor and setters
}
The adapter translates between your rich domain model and the anemic JPA entities required by Hibernate. This separation means your domain stays clean while the adapter handles the messiness of ORM frameworks.
Strategy Two: Parallel Implementation
For critical systems where you can’t risk breaking existing functionality, run the old and new implementations side by side. Route a small percentage of traffic to the new hexagonal architecture while the majority continues using the legacy code. Monitor carefully, compare results, and gradually increase traffic to the new implementation.
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@Autowired
private OrderService legacyService;
@Autowired
private CreateOrderUseCase newImplementation;
@Autowired
private FeatureToggleService featureToggles;
@PostMapping
public ResponseEntity<OrderResponse> createOrder(@RequestBody OrderRequest request) {
if (featureToggles.isEnabled("hexagonal-order-creation")) {
return createOrderNew(request);
} else {
return createOrderLegacy(request);
}
}
private ResponseEntity<OrderResponse> createOrderNew(OrderRequest request) {
CreateOrderCommand command = mapToCommand(request);
OrderId orderId = newImplementation.execute(command);
return ResponseEntity.ok(new OrderResponse(orderId));
}
private ResponseEntity<OrderResponse> createOrderLegacy(OrderRequest request) {
Order order = legacyService.createOrder(request);
return ResponseEntity.ok(new OrderResponse(order));
}
}
This approach requires maintaining both implementations temporarily, but it provides a safety net. You can roll back instantly if problems emerge. It’s more expensive in the short term but reduces risk significantly.
Handling Cross-Cutting Concerns
One challenge that emerges during migration involves cross-cutting concerns like transactions, security, and logging. In the legacy code, these were handled by Spring annotations scattered throughout service classes. In hexagonal architecture, you need explicit strategies.
Transactions become particularly interesting. Your use cases coordinate multiple operations that need to succeed or fail together, but the use case itself shouldn’t know about transaction boundaries. One solution uses the decorator pattern:
public class TransactionalCreateOrderUseCase implements CreateOrderUseCase {
private final CreateOrderUseCase delegate;
private final TransactionTemplate transactionTemplate;
public TransactionalCreateOrderUseCase(
CreateOrderUseCase delegate,
TransactionTemplate transactionTemplate
) {
this.delegate = delegate;
this.transactionTemplate = transactionTemplate;
}
@Override
public OrderId execute(CreateOrderCommand command) {
return transactionTemplate.execute(status ->
delegate.execute(command)
);
}
}
Configure this in your Spring configuration:
@Configuration
public class OrderConfiguration {
@Bean
public CreateOrderUseCase createOrderUseCase(
OrderRepository orderRepository,
InventoryService inventoryService,
PaymentService paymentService,
NotificationService notificationService,
TransactionTemplate transactionTemplate
) {
CreateOrderUseCase coreUseCase = new CreateOrderUseCaseImpl(
orderRepository,
inventoryService,
paymentService,
notificationService
);
return new TransactionalCreateOrderUseCase(
coreUseCase,
transactionTemplate
);
}
}
This keeps transaction management out of your business logic while ensuring proper behavior. The same pattern works for logging, monitoring, and security checks.
Testing Benefits
The payoff for this architectural complexity becomes apparent when writing tests. Your domain logic and use cases can be tested without any infrastructure:
class CreateOrderUseCaseTest {
private OrderRepository orderRepository;
private InventoryService inventoryService;
private PaymentService paymentService;
private NotificationService notificationService;
private CreateOrderUseCase useCase;
@BeforeEach
void setup() {
orderRepository = mock(OrderRepository.class);
inventoryService = mock(InventoryService.class);
paymentService = mock(PaymentService.class);
notificationService = mock(NotificationService.class);
useCase = new CreateOrderUseCaseImpl(
orderRepository,
inventoryService,
paymentService,
notificationService
);
}
@Test
void shouldCreateOrderWhenInventoryAvailableAndPaymentSucceeds() {
// Given
CreateOrderCommand command = aValidOrderCommand();
when(inventoryService.checkAvailability(any(), any()))
.thenReturn(true);
when(paymentService.processPayment(any(), any(), any()))
.thenReturn(PaymentResult.successful(aPaymentId()));
when(orderRepository.save(any()))
.thenAnswer(invocation -> invocation.getArgument(0));
// When
OrderId orderId = useCase.execute(command);
// Then
assertNotNull(orderId);
verify(orderRepository).save(any(Order.class));
verify(inventoryService).reserve(any(), any());
verify(notificationService).notifyOrderConfirmed(any());
}
@Test
void shouldThrowExceptionWhenInventoryInsufficient() {
// Given
CreateOrderCommand command = aValidOrderCommand();
when(inventoryService.checkAvailability(any(), any()))
.thenReturn(false);
// When / Then
assertThrows(
InsufficientInventoryException.class,
() -> useCase.execute(command)
);
verify(paymentService, never()).processPayment(any(), any(), any());
verify(orderRepository, never()).save(any());
}
}
These tests run in milliseconds because they don’t touch databases, networks, or frameworks. You can test complex business scenarios easily because you control all dependencies through simple mocks. The legacy equivalent required test containers, transaction rollbacks, and careful cleanup between tests.
Common Migration Pitfalls
Teams attempting this migration often stumble on similar issues. The biggest trap is creating a false hexagonal architecture that looks right but preserves the old problems. This happens when adapters leak into the domain or when ports mirror framework APIs too closely.
For example, defining a port that looks like this defeats the purpose:
class CreateOrderUseCaseTest {
private OrderRepository orderRepository;
private InventoryService inventoryService;
private PaymentService paymentService;
private NotificationService notificationService;
private CreateOrderUseCase useCase;
@BeforeEach
void setup() {
orderRepository = mock(OrderRepository.class);
inventoryService = mock(InventoryService.class);
paymentService = mock(PaymentService.class);
notificationService = mock(NotificationService.class);
useCase = new CreateOrderUseCaseImpl(
orderRepository,
inventoryService,
paymentService,
notificationService
);
}
@Test
void shouldCreateOrderWhenInventoryAvailableAndPaymentSucceeds() {
// Given
CreateOrderCommand command = aValidOrderCommand();
when(inventoryService.checkAvailability(any(), any()))
.thenReturn(true);
when(paymentService.processPayment(any(), any(), any()))
.thenReturn(PaymentResult.successful(aPaymentId()));
when(orderRepository.save(any()))
.thenAnswer(invocation -> invocation.getArgument(0));
// When
OrderId orderId = useCase.execute(command);
// Then
assertNotNull(orderId);
verify(orderRepository).save(any(Order.class));
verify(inventoryService).reserve(any(), any());
verify(notificationService).notifyOrderConfirmed(any());
}
@Test
void shouldThrowExceptionWhenInventoryInsufficient() {
// Given
CreateOrderCommand command = aValidOrderCommand();
when(inventoryService.checkAvailability(any(), any()))
.thenReturn(false);
// When / Then
assertThrows(
InsufficientInventoryException.class,
() -> useCase.execute(command)
);
verify(paymentService, never()).processPayment(any(), any(), any());
verify(orderRepository, never()).save(any());
}
}
Your domain doesn’t think in terms of generic CRUD operations. It thinks in terms of domain concepts:
// Better - expresses domain intent
public interface OrderRepository {
Order save(Order order);
Optional<Order> findById(OrderId id);
List<Order> findByCustomer(CustomerId customerId);
List<Order> findPendingOrders();
}
```
Another pitfall involves anemic domain models. Teams migrate the structure to hexagonal architecture but leave business logic scattered in use cases. The domain becomes a bag of getters and setters with no behavior. This misses the point entirely. Rich domain models encapsulate business rules and invariants.
Package structure confusion also derails migrations. Some teams create packages by architectural layer (`adapters`, `ports`, `domain`) which scatters related concepts across the codebase. Better to organize by feature or bounded context, with each feature containing its own layers:
```
com.example.order
├── domain
│ ├── Order.java
│ ├── OrderLine.java
│ └── port
│ ├── OrderRepository.java
│ └── InventoryService.java
├── application
│ ├── CreateOrderUseCase.java
│ └── CancelOrderUseCase.java
└── adapter
├── persistence
│ └── JpaOrderRepository.java
├── messaging
│ └── KafkaInventoryService.java
└── web
└── OrderController.java
When Not to Migrate
Hexagonal Architecture isn’t always worth the investment. Small applications with straightforward CRUD operations gain little from this complexity. The abstraction layers add cognitive overhead without meaningful benefits when your entire application is a thin layer over database tables.
Applications nearing end-of-life or scheduled for replacement shouldn’t undergo major architectural refactoring. If you’re rewriting in six months anyway, spend your time delivering features instead. Similarly, teams unfamiliar with domain-driven design principles might struggle more with hexagonal architecture than the problems it solves.
The migration also requires organizational buy-in. You need time to refactor without delivering new features at the usual pace. Stakeholders need to understand why you’re “rewriting working code.” If your organization measures productivity purely by feature velocity, this architectural work looks like wasted effort.
Making the Business Case
Convincing management to support architectural refactoring requires framing the benefits in business terms. Faster test execution means quicker feedback and more confidence in releases. Decoupled components allow teams to work in parallel without stepping on each other. The ability to swap implementations makes the codebase adaptable to changing requirements.
Quantify the current pain points. How much time does your team spend debugging production issues caused by tight coupling? How often do small changes ripple through multiple layers of the application? What’s the cost of maintaining complex test infrastructure? These metrics help demonstrate that architectural debt has real business impact.
Start small with a pilot project. Pick a bounded context that’s causing pain and migrate it to hexagonal architecture. Measure the results—test execution time, defect rates, time to implement changes. Use these metrics to justify expanding the approach to other parts of the system.
The Long Game
Migrating to hexagonal architecture is a marathon, not a sprint. Real applications accumulate years of technical debt, business logic hidden in stored procedures, and dependencies on deprecated libraries. The migration might take months or years depending on your codebase size and team capacity.
Accept that you’ll have a hybrid architecture for a significant period. Legacy code coexists with new hexagonal components. This isn’t failure—it’s pragmatism. Focus on preventing new code from adding to technical debt while gradually refactoring critical paths.
Document your architecture decisions and patterns. New team members need to understand why certain code follows hexagonal principles while other code doesn’t. Architecture decision records help maintain context over time and prevent backsliding into old patterns.
The goal isn’t perfection. It’s creating a codebase that’s easier to understand, modify, and test than what you started with. If the migration improves these qualities incrementally, you’re succeeding even if you never achieve textbook-perfect hexagonal architecture.
Useful Resources
- Hexagonal Architecture by Alistair Cockburn: https://alistair.cockburn.us/hexagonal-architecture/
The original article introducing the Ports and Adapters pattern, essential reading for understanding the fundamentals. - Domain-Driven Design by Eric Evans: https://www.domainlanguage.com/ddd/
While not specifically about hexagonal architecture, DDD concepts are crucial for building meaningful domain models. - Get Your Hands Dirty on Clean Architecture: https://www.packtpub.com/product/get-your-hands-dirty-on-clean-architecture/9781839211966
Practical guide with Java examples for implementing hexagonal architecture in real projects. - Refactoring by Martin Fowler: https://refactoring.com/
Essential techniques for safely transforming existing code, critical for migration strategies. - Spring Boot Architecture Best Practices: https://spring.io/guides
Official Spring guides that complement hexagonal architecture when using Spring framework. - ArchUnit: https://www.archunit.org/
Testing library for enforcing architectural rules in Java, helps maintain boundaries during migration.

