When building enterprise Spring Boot applications, the choice of software architecture is crucial for long-term maintainability. Historically, many developers relied on the classic three-tier architecture (Presentation, Business, and Data Access layers). While easy to understand, this model has a major drawback: the business logic is often tightly coupled to the database technology (like Spring Data JPA or Hibernate). Changing the database, migrating to a NoSQL database, or integrating a new third-party API becomes a nightmare because database-specific code leaks into core domain classes. To prevent this, modern developers use Hexagonal Architecture (also known as Ports and Adapters).
The Core Concept of Hexagonal Architecture
Created by Alistair Cockburn, Hexagonal Architecture aims to isolate the core business logic (the Domain) from external dependencies such as databases, user interfaces, message queues, and external APIs.
In Hexagonal Architecture, the core business logic sits inside a hexagon. The outer world (adapters) interacts with the core through ports.
- The Domain: Contains pure business logic, entities, and validation rules. It should be written in pure Java and have absolutely no framework dependencies (no Spring annotations, no JPA
@Entityimports, etc.). - Ports: Interfaces that act as gateways to the domain.
- Adapters: Implementations of ports or components that trigger ports.
Inbound vs. Outbound Ports and Adapters
To build a hexagonal system, you must classify interactions into Driving (Inbound) and Driven (Outbound):
#### 1. Inbound (Driving) Ports and Adapters
Inbound ports define how the outer world can trigger the domain.
- Inbound Port: An interface defined inside the domain that declares a business capability. E.g.,
CreateOrderUseCase. - Inbound Adapter: A framework-specific component that receives external requests and calls the Inbound Port. E.g., a Spring MVC
@RestControllerthat maps HTTP POST requests to the domain service, or a Kafka consumer receiving orders.
#### 2. Outbound (Driven) Ports and Adapters
Outbound ports define how the domain communicates with the outer world.
- Outbound Port: An interface defined inside the domain specifying what it needs from infrastructure. E.g.,
OrderRepositoryPort. - Outbound Adapter: A framework-specific implementation of the Outbound Port. E.g.,
JpaOrderRepositoryAdapterwhich implementsOrderRepositoryPortusing Spring Data JPA.
Implementing Hexagonal Architecture in Spring Boot
Let's look at a concrete code implementation. First, our project structure is organized as:
com.example.order
├── domain
│ ├── model
│ │ └── Order.java
│ └── service
│ └── OrderDomainService.java
├── ports
│ ├── inbound
│ │ └── CreateOrderUseCase.java
│ └── outbound
│ └── OrderRepositoryPort.java
└── adapters
├── inbound
│ └── web
│ └── OrderController.java
└── outbound
└── db
├── JpaOrderRepository.java
└── OrderRepositoryAdapter.java
Let's write our pure domain model:
// com.example.order.domain.model.Order.java
public class Order {
private final UUID id;
private final String product;
private double price;
public Order(UUID id, String product, double price) {
if (price < 0) {
throw new IllegalArgumentException("Price cannot be negative");
}
this.id = id;
this.product = product;
this.price = price;
}
// Getters and domain logic only
}
Now, the Inbound Port (UseCase):
// com.example.order.ports.inbound.CreateOrderUseCase.java
public interface CreateOrderUseCase {
Order create(String product, double price);
}
The Outbound Port (Repository Interface):
// com.example.order.ports.outbound.OrderRepositoryPort.java
public interface OrderRepositoryPort {
void save(Order order);
}
The Domain Service implementing the Inbound Port, utilizing the Outbound Port:
// com.example.order.domain.service.OrderDomainService.java
public class OrderDomainService implements CreateOrderUseCase {
private final OrderRepositoryPort repositoryPort;
public OrderDomainService(OrderRepositoryPort repositoryPort) {
this.repositoryPort = repositoryPort;
}
@Override
public Order create(String product, double price) {
Order order = new Order(UUID.randomUUID(), product, price);
repositoryPort.save(order);
return order;
}
}
The Outbound Database Adapter:
// com.example.order.adapters.outbound.db.OrderRepositoryAdapter.java
@Component
@RequiredArgsConstructor
public class OrderRepositoryAdapter implements OrderRepositoryPort {
private final JpaOrderRepository jpaRepository;
@Override
public void save(Order order) {
// Map domain model to database entity
OrderEntity entity = new OrderEntity(order.getId(), order.getProduct(), order.getPrice());
jpaRepository.save(entity);
}
}
Finally, the Inbound Web Adapter (Spring REST Controller):
// com.example.order.adapters.inbound.web.OrderController.java
@RestController
@RequestMapping("/orders")
@RequiredArgsConstructor
public class OrderController {
private final CreateOrderUseCase createOrderUseCase;
@PostMapping
public ResponseEntity<OrderResponse> createOrder(@RequestBody OrderRequest request) {
Order order = createOrderUseCase.create(request.getProduct(), request.getPrice());
return ResponseEntity.ok(new OrderResponse(order.getId(), "CREATED"));
}
}
Configuration and Bean Instantiation
Since OrderDomainService is written in pure Java (inside the domain package) and does not use Spring's @Service annotation, Spring will not detect it during component scanning. We must configure and instantiate it manually using a configuration class:
@Configuration
public class OrderConfig {
@Bean
public CreateOrderUseCase createOrderUseCase(OrderRepositoryPort repositoryPort) {
return new OrderDomainService(repositoryPort);
}
}
Advantages and Trade-offs
#### Advantages
- Highly Testable: You can easily unit-test the business logic in
OrderDomainServiceby passing a mockedOrderRepositoryPortwithout starting Spring Boot context or database containers. - Framework Independent: If you decide to migrate from Spring Boot to Quarkus, or from SQL to MongoDB, the domain code remains completely untouched. Only the adapters change.
#### Trade-offs
- Increased Boilerplate: You have to maintain double the amount of classes and map objects between domain entities and database entities.
- Steeper Learning Curve: Junior developers might struggle to understand the direction of dependencies.
Interview Strategy for Indian Software Engineers
If you are interviewing for Senior Software Engineer or Architect roles:
- Focus on Testability: Highlight how Hexagonal Architecture facilitates pure unit testing of business logic, avoiding slow integration tests.
- Acknowledge the Mapping overhead: Explain that you use mapping frameworks (like MapStruct) or manually write mappers to handle the conversion overhead between domain objects and DB entities.