Core Java

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

Eleftheria Drosopoulou

Eleftheria is an Experienced Business Analyst with a robust background in the computer software industry. Proficient in Computer Software Training, Digital Marketing, HTML Scripting, and Microsoft Office, they bring a wealth of technical skills to the table. Additionally, she has a love for writing articles on various tech subjects, showcasing a talent for translating complex concepts into accessible content.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button