Hexagonal Architecture, also known as Ports and Adapters, is a software design pattern that aims to create loosely coupled application components that can be easily connected to their software environment through ports and adapters. This architecture emphasizes the separation of concerns by isolating the core business logic from external concerns like databases, web frameworks, and third-party services.
This article explores Hexagonal Architecture principles, implementation patterns, and practical examples in Java.
The Problem with Traditional Layered Architecture
Traditional layered architectures (Presentation → Business → Data Access) often lead to:
- Framework Coupling: Business logic becomes dependent on specific web frameworks
- Database Coupling: Domain models are tied to persistence technologies
- Test Complexity: Difficult to test business logic in isolation
- Infrastructure Lock-in: Hard to change databases, messaging systems, or APIs
- Tight Coupling: Changes in one layer ripple through the entire system
Hexagonal Architecture solves these issues by making the application agnostic to external technologies.
Core Concepts of Hexagonal Architecture
1. The Core Domain: The innermost part containing business logic and rules
2. Ports: Interfaces that define how the application communicates with the outside world
3. Adapters: Implementations that connect ports to external systems
4. Driving Side (Primary): Initiates interactions (HTTP requests, CLI commands)
5. Driven Side (Secondary): Provides services needed by the core (databases, external APIs)
┌─────────────────────────────────────────────────────────────────┐ │ DRIVING ADAPTERS (Input) │ │ ┌─────────────┐ ┌─────────────┐ ┌────────────────────┐ │ │ │ REST API │ │ Web UI │ │ CLI Tool │ │ │ │ Adapter │ │ Adapter │ │ Adapter │ │ │ └─────────────┘ └─────────────┘ └────────────────────┘ │ │ │ │ │ │ ├─────────────┼──────────────┼─────────────────────┼─────────────┤ │ PORTS (Input) │ │ ┌────────────────────────────┐ │ │ │ UserService Port │ │ │ │ OrderService Port │ │ │ └────────────────────────────┘ │ │ │ │ ├─────────────────────────────┼──────────────────────────────────┤ │ APPLICATION CORE │ │ ┌─────────────┐ │ ┌─────────────┐ │ │ │ Domain │ │ │ Application │ │ │ │ Models │◄─────┼──────►│ Services │ │ │ └─────────────┘ │ └─────────────┘ │ │ │ │ ├─────────────────────────────┼──────────────────────────────────┤ │ PORTS (Output) │ │ ┌────────────────────────────┐ │ │ │ UserRepository Port │ │ │ │ PaymentService Port │ │ │ │ Notification Port │ │ │ └────────────────────────────┘ │ │ │ │ │ │ ├─────────────┼──────────────┼─────────────────────┼─────────────┤ │ DRIVEN ADAPTERS (Output) │ │ ┌─────────────┐ ┌─────────────┐ ┌────────────────────┐ │ │ │ JPA/H2 │ │ MongoDB │ │ Email Service │ │ │ │ Adapter │ │ Adapter │ │ Adapter │ │ │ └─────────────┘ └─────────────┘ └────────────────────┘ │ └─────────────────────────────────────────────────────────────────┘
Project Structure
Let's examine the typical project structure for a Hexagonal Architecture:
src/ ├── main/ │ ├── java/ │ │ └── com/ │ │ └── example/ │ │ └── ecommerce/ │ │ ├── application/ # Application Core │ │ │ ├── service/ # Application Services │ │ │ ├── port/ # Port Interfaces │ │ │ │ ├── in/ # Input Ports (Driving) │ │ │ │ └── out/ # Output Ports (Driven) │ │ │ └── dto/ # Data Transfer Objects │ │ ├── domain/ # Domain Layer │ │ │ ├── model/ # Domain Models │ │ │ ├── exception/ # Domain Exceptions │ │ │ └── service/ # Domain Services │ │ └── adapter/ # Adapters Layer │ │ ├── in/ # Driving Adapters │ │ │ ├── web/ # REST Controllers │ │ │ └── cli/ # Command Line │ │ └── out/ # Driven Adapters │ │ ├── persistence/ # Database Adapters │ │ ├── external/ # External Services │ │ └── messaging/ # Message Brokers │ └── resources/ └── test/ └── java/ └── com/example/ecommerce/ ├── application/ # Core Tests ├── domain/ # Domain Tests └── adapter/ # Adapter Tests
Domain Layer (The Core)
The domain layer contains the business logic and is completely isolated from external concerns.
1. Domain Models:
package com.example.ecommerce.domain.model;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
public class Order {
private final UUID id;
private final UUID customerId;
private final List<OrderItem> items;
private OrderStatus status;
private final LocalDateTime createdAt;
private LocalDateTime updatedAt;
public Order(UUID customerId) {
this.id = UUID.randomUUID();
this.customerId = customerId;
this.items = new ArrayList<>();
this.status = OrderStatus.PENDING;
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
// Business logic methods
public void addItem(Product product, int quantity) {
if (quantity <= 0) {
throw new IllegalArgumentException("Quantity must be positive");
}
if (status != OrderStatus.PENDING) {
throw new IllegalStateException("Cannot modify order in " + status + " status");
}
this.items.add(new OrderItem(product, quantity));
this.updatedAt = LocalDateTime.now();
}
public void placeOrder() {
if (items.isEmpty()) {
throw new IllegalStateException("Cannot place empty order");
}
this.status = OrderStatus.PLACED;
this.updatedAt = LocalDateTime.now();
}
public BigDecimal calculateTotal() {
return items.stream()
.map(OrderItem::getSubtotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
// Getters
public UUID getId() { return id; }
public UUID getCustomerId() { return customerId; }
public List<OrderItem> getItems() { return new ArrayList<>(items); }
public OrderStatus getStatus() { return status; }
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
}
public class OrderItem {
private final Product product;
private final int quantity;
public OrderItem(Product product, int quantity) {
this.product = product;
this.quantity = quantity;
}
public BigDecimal getSubtotal() {
return product.getPrice().multiply(BigDecimal.valueOf(quantity));
}
// Getters
public Product getProduct() { return product; }
public int getQuantity() { return quantity; }
}
public class Product {
private final UUID id;
private final String name;
private final String description;
private final BigDecimal price;
private final int stock;
public Product(UUID id, String name, String description, BigDecimal price, int stock) {
this.id = id;
this.name = name;
this.description = description;
this.price = price;
this.stock = stock;
}
// Business logic
public boolean isInStock() {
return stock > 0;
}
// Getters
public UUID getId() { return id; }
public String getName() { return name; }
public String getDescription() { return description; }
public BigDecimal getPrice() { return price; }
public int getStock() { return stock; }
}
public enum OrderStatus {
PENDING, PLACED, PAID, SHIPPED, DELIVERED, CANCELLED
}
2. Domain Exceptions:
package com.example.ecommerce.domain.exception;
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(String message) {
super(message);
}
}
public class InsufficientStockException extends RuntimeException {
public InsufficientStockException(String message) {
super(message);
}
}
Ports (Interfaces)
Ports define the contracts for interacting with the application core.
1. Input Ports (Driving Side):
package com.example.ecommerce.application.port.in;
import com.example.ecommerce.application.dto.CreateOrderRequest;
import com.example.ecommerce.application.dto.OrderResponse;
import java.util.List;
import java.util.UUID;
public interface OrderUseCase {
OrderResponse createOrder(CreateOrderRequest request);
OrderResponse getOrder(UUID orderId);
List<OrderResponse> getCustomerOrders(UUID customerId);
void cancelOrder(UUID orderId);
}
public interface ProductUseCase {
List<ProductResponse> getAllProducts();
ProductResponse getProduct(UUID productId);
}
2. Output Ports (Driven Side):
package com.example.ecommerce.application.port.out;
import com.example.ecommerce.domain.model.Order;
import com.example.ecommerce.domain.model.Product;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface OrderRepository {
Order save(Order order);
Optional<Order> findById(UUID id);
List<Order> findByCustomerId(UUID customerId);
boolean existsById(UUID id);
}
public interface ProductRepository {
List<Product> findAll();
Optional<Product> findById(UUID id);
Product save(Product product);
}
public interface PaymentPort {
PaymentResult processPayment(UUID orderId, BigDecimal amount);
boolean refundPayment(UUID paymentId);
}
public interface NotificationPort {
void sendOrderConfirmation(UUID orderId, String customerEmail);
void sendShippingNotification(UUID orderId, String customerEmail);
}
Application Services
Application services orchestrate the domain logic and use the ports.
package com.example.ecommerce.application.service;
import com.example.ecommerce.application.port.in.OrderUseCase;
import com.example.ecommerce.application.port.out.OrderRepository;
import com.example.ecommerce.application.port.out.ProductRepository;
import com.example.ecommerce.application.port.out.PaymentPort;
import com.example.ecommerce.application.port.out.NotificationPort;
import com.example.ecommerce.application.dto.*;
import com.example.ecommerce.domain.model.Order;
import com.example.ecommerce.domain.model.Product;
import com.example.ecommerce.domain.exception.OrderNotFoundException;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
import java.util.List;
import java.util.stream.Collectors;
public class OrderService implements OrderUseCase {
private final OrderRepository orderRepository;
private final ProductRepository productRepository;
private final PaymentPort paymentPort;
private final NotificationPort notificationPort;
public OrderService(OrderRepository orderRepository,
ProductRepository productRepository,
PaymentPort paymentPort,
NotificationPort notificationPort) {
this.orderRepository = orderRepository;
this.productRepository = productRepository;
this.paymentPort = paymentPort;
this.notificationPort = notificationPort;
}
@Override
@Transactional
public OrderResponse createOrder(CreateOrderRequest request) {
// Create domain entity
Order order = new Order(request.getCustomerId());
// Add items to order
for (OrderItemRequest itemRequest : request.getItems()) {
Product product = productRepository.findById(itemRequest.getProductId())
.orElseThrow(() -> new IllegalArgumentException("Product not found"));
order.addItem(product, itemRequest.getQuantity());
}
// Place order
order.placeOrder();
// Save order
Order savedOrder = orderRepository.save(order);
// Process payment
paymentPort.processPayment(savedOrder.getId(), savedOrder.calculateTotal());
// Send notification
notificationPort.sendOrderConfirmation(savedOrder.getId(), request.getCustomerEmail());
return mapToResponse(savedOrder);
}
@Override
public OrderResponse getOrder(UUID orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException("Order not found with id: " + orderId));
return mapToResponse(order);
}
@Override
public List<OrderResponse> getCustomerOrders(UUID customerId) {
return orderRepository.findByCustomerId(customerId).stream()
.map(this::mapToResponse)
.collect(Collectors.toList());
}
@Override
@Transactional
public void cancelOrder(UUID orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException("Order not found with id: " + orderId));
// Domain logic for cancellation
// order.cancel(); // Would implement cancel method in domain
orderRepository.save(order);
}
private OrderResponse mapToResponse(Order order) {
List<OrderItemResponse> items = order.getItems().stream()
.map(item -> new OrderItemResponse(
item.getProduct().getId(),
item.getProduct().getName(),
item.getQuantity(),
item.getSubtotal()
))
.collect(Collectors.toList());
return new OrderResponse(
order.getId(),
order.getCustomerId(),
items,
order.calculateTotal(),
order.getStatus().name(),
order.getCreatedAt(),
order.getUpdatedAt()
);
}
}
DTOs (Data Transfer Objects):
package com.example.ecommerce.application.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
public record CreateOrderRequest(
UUID customerId,
String customerEmail,
List<OrderItemRequest> items
) {}
public record OrderItemRequest(
UUID productId,
int quantity
) {}
public record OrderResponse(
UUID id,
UUID customerId,
List<OrderItemResponse> items,
BigDecimal totalAmount,
String status,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {}
public record OrderItemResponse(
UUID productId,
String productName,
int quantity,
BigDecimal subtotal
) {}
public record ProductResponse(
UUID id,
String name,
String description,
BigDecimal price,
int stock
) {}
Driving Adapters (Primary/Input)
These adapters handle incoming requests to the application.
1. REST Controller:
package com.example.ecommerce.adapter.in.web;
import com.example.ecommerce.application.port.in.OrderUseCase;
import com.example.ecommerce.application.dto.CreateOrderRequest;
import com.example.ecommerce.application.dto.OrderResponse;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderUseCase orderUseCase;
public OrderController(OrderUseCase orderUseCase) {
this.orderUseCase = orderUseCase;
}
@PostMapping
public ResponseEntity<OrderResponse> createOrder(@RequestBody CreateOrderRequest request) {
OrderResponse response = orderUseCase.createOrder(request);
return ResponseEntity.ok(response);
}
@GetMapping("/{orderId}")
public ResponseEntity<OrderResponse> getOrder(@PathVariable UUID orderId) {
OrderResponse response = orderUseCase.getOrder(orderId);
return ResponseEntity.ok(response);
}
@GetMapping("/customer/{customerId}")
public ResponseEntity<List<OrderResponse>> getCustomerOrders(@PathVariable UUID customerId) {
List<OrderResponse> responses = orderUseCase.getCustomerOrders(customerId);
return ResponseEntity.ok(responses);
}
@PostMapping("/{orderId}/cancel")
public ResponseEntity<Void> cancelOrder(@PathVariable UUID orderId) {
orderUseCase.cancelOrder(orderId);
return ResponseEntity.ok().build();
}
}
2. CLI Adapter:
package com.example.ecommerce.adapter.in.cli;
import com.example.ecommerce.application.port.in.OrderUseCase;
import org.springframework.shell.standard.ShellComponent;
import org.springframework.shell.standard.ShellMethod;
import java.util.UUID;
@ShellComponent
public class OrderCli {
private final OrderUseCase orderUseCase;
public OrderCli(OrderUseCase orderUseCase) {
this.orderUseCase = orderUseCase;
}
@ShellMethod(key = "get-order", value = "Get order by ID")
public String getOrder(String orderId) {
try {
var order = orderUseCase.getOrder(UUID.fromString(orderId));
return String.format("Order: %s, Total: %s", order.id(), order.totalAmount());
} catch (Exception e) {
return "Error: " + e.getMessage();
}
}
}
Driven Adapters (Secondary/Output)
These adapters implement the output ports for external systems.
1. JPA Database Adapter:
package com.example.ecommerce.adapter.out.persistence;
import com.example.ecommerce.application.port.out.OrderRepository;
import com.example.ecommerce.domain.model.Order;
import com.example.ecommerce.adapter.out.persistence.entity.OrderEntity;
import com.example.ecommerce.adapter.out.persistence.entity.OrderItemEntity;
import com.example.ecommerce.adapter.out.persistence.mapper.OrderMapper;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
@Repository
public class OrderJpaAdapter implements OrderRepository {
private final OrderJpaRepository orderJpaRepository;
private final OrderMapper orderMapper;
public OrderJpaAdapter(OrderJpaRepository orderJpaRepository, OrderMapper orderMapper) {
this.orderJpaRepository = orderJpaRepository;
this.orderMapper = orderMapper;
}
@Override
public Order save(Order order) {
OrderEntity entity = orderMapper.toEntity(order);
OrderEntity savedEntity = orderJpaRepository.save(entity);
return orderMapper.toDomain(savedEntity);
}
@Override
public Optional<Order> findById(UUID id) {
return orderJpaRepository.findById(id)
.map(orderMapper::toDomain);
}
@Override
public List<Order> findByCustomerId(UUID customerId) {
return orderJpaRepository.findByCustomerId(customerId).stream()
.map(orderMapper::toDomain)
.collect(Collectors.toList());
}
@Override
public boolean existsById(UUID id) {
return orderJpaRepository.existsById(id);
}
}
// JPA Entity
@Entity
@Table(name = "orders")
public class OrderEntity {
@Id
private UUID id;
@Column(name = "customer_id", nullable = false)
private UUID customerId;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private OrderStatus status;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<OrderItemEntity> items;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
// Constructors, getters, setters
}
// JPA Repository
public interface OrderJpaRepository extends JpaRepository<OrderEntity, UUID> {
List<OrderEntity> findByCustomerId(UUID customerId);
}
// Mapper
@Component
public class OrderMapper {
public OrderEntity toEntity(Order order) {
OrderEntity entity = new OrderEntity();
entity.setId(order.getId());
entity.setCustomerId(order.getCustomerId());
entity.setStatus(order.getStatus());
entity.setCreatedAt(order.getCreatedAt());
entity.setUpdatedAt(order.getUpdatedAt());
List<OrderItemEntity> itemEntities = order.getItems().stream()
.map(this::toItemEntity)
.collect(Collectors.toList());
entity.setItems(itemEntities);
itemEntities.forEach(item -> item.setOrder(entity));
return entity;
}
public Order toDomain(OrderEntity entity) {
Order order = new Order(entity.getCustomerId());
// Reflection or setters to set the ID and other fields
// In practice, you might need to adjust the domain constructor
return order;
}
}
2. External Payment Service Adapter:
package com.example.ecommerce.adapter.out.external;
import com.example.ecommerce.application.port.out.PaymentPort;
import com.example.ecommerce.application.dto.PaymentResult;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.math.BigDecimal;
import java.util.UUID;
@Component
public class PaymentServiceAdapter implements PaymentPort {
private final RestTemplate restTemplate;
private final String paymentServiceUrl;
public PaymentServiceAdapter(RestTemplate restTemplate,
@Value("${payment.service.url}") String paymentServiceUrl) {
this.restTemplate = restTemplate;
this.paymentServiceUrl = paymentServiceUrl;
}
@Override
public PaymentResult processPayment(UUID orderId, BigDecimal amount) {
PaymentRequest request = new PaymentRequest(orderId, amount);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<PaymentRequest> entity = new HttpEntity<>(request, headers);
try {
PaymentResponse response = restTemplate.postForObject(
paymentServiceUrl + "/payments",
entity,
PaymentResponse.class
);
return new PaymentResult(true, response.getPaymentId(), "Payment successful");
} catch (Exception e) {
return new PaymentResult(false, null, "Payment failed: " + e.getMessage());
}
}
@Override
public boolean refundPayment(UUID paymentId) {
// Implementation for refund
return true;
}
// Request/Response DTOs for the external service
private static class PaymentRequest {
private final UUID orderId;
private final BigDecimal amount;
public PaymentRequest(UUID orderId, BigDecimal amount) {
this.orderId = orderId;
this.amount = amount;
}
// Getters
public UUID getOrderId() { return orderId; }
public BigDecimal getAmount() { return amount; }
}
private static class PaymentResponse {
private UUID paymentId;
private String status;
// Getters and setters
public UUID getPaymentId() { return paymentId; }
public void setPaymentId(UUID paymentId) { this.paymentId = paymentId; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
}
}
Configuration and Dependency Injection
Spring Configuration:
package com.example.ecommerce.config;
import com.example.ecommerce.application.port.in.OrderUseCase;
import com.example.ecommerce.application.port.out.OrderRepository;
import com.example.ecommerce.application.port.out.ProductRepository;
import com.example.ecommerce.application.port.out.PaymentPort;
import com.example.ecommerce.application.port.out.NotificationPort;
import com.example.ecommerce.application.service.OrderService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ApplicationConfig {
@Bean
public OrderUseCase orderUseCase(OrderRepository orderRepository,
ProductRepository productRepository,
PaymentPort paymentPort,
NotificationPort notificationPort) {
return new OrderService(orderRepository, productRepository, paymentPort, notificationPort);
}
}
Testing in Hexagonal Architecture
1. Domain Unit Tests:
package com.example.ecommerce.domain.model;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
class OrderTest {
@Test
void shouldAddItemToOrder() {
// Given
UUID customerId = UUID.randomUUID();
Order order = new Order(customerId);
Product product = new Product(UUID.randomUUID(), "Test Product", "Description",
BigDecimal.valueOf(29.99), 10);
// When
order.addItem(product, 2);
// Then
assertEquals(1, order.getItems().size());
assertEquals(BigDecimal.valueOf(59.98), order.calculateTotal());
}
@Test
void shouldNotAddItemWithZeroQuantity() {
// Given
Order order = new Order(UUID.randomUUID());
Product product = new Product(UUID.randomUUID(), "Test Product", "Description",
BigDecimal.TEN, 10);
// When & Then
assertThrows(IllegalArgumentException.class, () -> {
order.addItem(product, 0);
});
}
}
2. Application Service Tests (Mocking Ports):
package com.example.ecommerce.application.service;
import com.example.ecommerce.application.port.out.OrderRepository;
import com.example.ecommerce.application.port.out.ProductRepository;
import com.example.ecommerce.application.port.out.PaymentPort;
import com.example.ecommerce.application.port.out.NotificationPort;
import com.example.ecommerce.domain.model.Order;
import com.example.ecommerce.domain.model.Product;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.util.Optional;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private ProductRepository productRepository;
@Mock
private PaymentPort paymentPort;
@Mock
private NotificationPort notificationPort;
@InjectMocks
private OrderService orderService;
@Test
void shouldCreateOrderSuccessfully() {
// Given
UUID productId = UUID.randomUUID();
UUID customerId = UUID.randomUUID();
Product product = new Product(productId, "Test Product", "Description",
BigDecimal.valueOf(25.0), 10);
when(productRepository.findById(productId)).thenReturn(Optional.of(product));
when(orderRepository.save(any(Order.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
var request = new CreateOrderRequest(customerId, "[email protected]",
List.of(new OrderItemRequest(productId, 2)));
var response = orderService.createOrder(request);
// Then
assertNotNull(response);
assertEquals(customerId, response.customerId());
assertEquals(BigDecimal.valueOf(50.0), response.totalAmount());
verify(orderRepository).save(any(Order.class));
verify(paymentPort).processPayment(any(UUID.class), any(BigDecimal.class));
verify(notificationPort).sendOrderConfirmation(any(UUID.class), anyString());
}
}
3. Adapter Integration Tests:
package com.example.ecommerce.adapter.in.web;
import com.example.ecommerce.application.port.in.OrderUseCase;
import com.example.ecommerce.application.dto.OrderResponse;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import java.math.BigDecimal;
import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private OrderUseCase orderUseCase;
@Test
void shouldReturnOrder() throws Exception {
// Given
UUID orderId = UUID.randomUUID();
UUID customerId = UUID.randomUUID();
OrderResponse orderResponse = new OrderResponse(
orderId, customerId, List.of(), BigDecimal.valueOf(100.0),
"PLACED", null, null
);
when(orderUseCase.getOrder(orderId)).thenReturn(orderResponse);
// When & Then
mockMvc.perform(get("/api/orders/{orderId}", orderId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(orderId.toString()))
.andExpect(jsonPath("$.totalAmount").value(100.0));
}
}
Benefits of Hexagonal Architecture
- Testability: Core logic can be tested without infrastructure concerns
- Flexibility: Easy to swap implementations (database, external services)
- Maintainability: Clear separation of concerns and responsibilities
- Framework Independence: Business logic doesn't depend on specific frameworks
- Parallel Development: Teams can work on different parts independently
- Evolutionary Design: Easy to add new features and adapt to changes
Best Practices
- Dependency Rule: Dependencies always point inward (core has no outward dependencies)
- Use Interfaces: All external interactions go through ports (interfaces)
- Rich Domain Models: Put business logic in domain objects, not services
- Avoid Annotations in Core: Keep framework annotations out of domain and application layers
- Package by Feature: Organize code around business capabilities, not technical layers
- Use DTOs: Separate domain models from external representations
When to Use Hexagonal Architecture
Good For:
- Complex business domains with frequently changing rules
- Applications requiring multiple UIs (Web, Mobile, CLI)
- Systems with multiple data sources or external integrations
- Long-lived projects where maintainability is crucial
- Teams practicing Domain-Driven Design
Consider Alternatives For:
- Simple CRUD applications
- Prototypes and MVPs
- Applications with simple business logic
- When development speed is more important than long-term maintainability
Conclusion
Hexagonal Architecture provides a robust foundation for building maintainable, testable, and flexible Java applications. By strictly separating the core business logic from technical concerns through ports and adapters, you create a system that can:
- Evolve independently of technology choices
- Test business rules in complete isolation
- Swap implementations without affecting core logic
- Onboard new team members more quickly with clear boundaries
- Adapt to changing requirements with minimal impact
While it introduces some initial complexity, the long-term benefits in maintainability, testability, and flexibility make Hexagonal Architecture an excellent choice for enterprise applications and systems with complex business domains.