Career Guide

Demystifying Hexagonal Architecture in Spring Boot Applications

Hexagonal Architecture (Ports and Adapters) decouples your core business logic from databases, UI, and external APIs. Learn how to implement it in Spring Boot.

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 @Entity imports, 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 @RestController that 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., JpaOrderRepositoryAdapter which implements OrderRepositoryPort using 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

  1. Highly Testable: You can easily unit-test the business logic in OrderDomainService by passing a mocked OrderRepositoryPort without starting Spring Boot context or database containers.
  2. 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

  1. Increased Boilerplate: You have to maintain double the amount of classes and map objects between domain entities and database entities.
  2. 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:

  1. Focus on Testability: Highlight how Hexagonal Architecture facilitates pure unit testing of business logic, avoiding slow integration tests.
  2. 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.

📢 Share this article