A Detailed Guide to Hexagonal Architecture with Examples
Introduction to Hexagonal Architecture
Hexagonal Architecture, also known as Ports and Adapters, was introduced by Alistair Cockburn. It’s a software design pattern that aims to create a clear separation between the core business logic and external systems. This approach makes applications easier to test, maintain, and extend. It ensures that your application core remains independent of external services, databases, frameworks, or tools.
This article will dive into the core concepts of Hexagonal Architecture, the components involved, and provide detailed examples to help you grasp the concept effectively.
Why Hexagonal Architecture?
In traditional monolithic or layered architectures, applications often have dependencies tightly coupled with external systems like databases, UI, or third-party services. This makes it challenging to change any external service or adapt to new technologies. Hexagonal Architecture provides a solution by decoupling the core business logic from external systems, allowing flexibility in how these external systems interact with the core application.
Key Benefits:
- Decoupling: Core logic doesn’t depend on infrastructure details.
- Flexibility: Easily swap adapters or external systems (e.g., switching databases).
- Testability: Business logic can be unit tested without relying on external systems like databases or web services.
- Maintainability: Clear separation of concerns ensures easier management of code.
Core Components of Hexagonal Architecture
Hexagonal Architecture is built around three main components:
- Domain: The heart of the system, containing the business logic.
- Ports: Interfaces that define communication between the core business logic and external systems.
- Adapters: Concrete implementations of the ports. They allow external systems to interact with the domain.
1. Domain
The domain represents your business logic and entities, independent of frameworks or infrastructure. This part of the system does not depend on anything external.
Example:
// Domain Model
public class Product {
private Long id;
private String name;
private double price;
// Business logic method
public void applyDiscount(double percentage) {
if (percentage > 0 && percentage <= 100) {
this.price = this.price - (this.price * (percentage / 100));
}
}// Getters and setters
}
In this example, the Product
class represents a core entity, with a business logic method (applyDiscount
) that modifies the internal state.
2. Ports
Ports act as boundaries between the core business logic and the outside world. They define how external systems (like databases, APIs, or messaging systems) interact with the domain.
There are two types of ports:
- Inbound Ports: Define how the application accepts input (e.g., service interfaces, REST controllers).
- Outbound Ports: Define how the core logic communicates with external systems (e.g., database or message queues).
Example:
// Inbound Port - Interface for services that handle products
public interface ProductService {
Product getProductById(Long id);
void applyDiscountToProduct(Long id, double percentage);
}
// Outbound Port - Interface for persistence
public interface ProductRepository {
Product findById(Long id);
void save(Product product);
}
In this example, ProductService
is an inbound port that defines the operations that the application can perform. ProductRepository
is an outbound port for database interactions.
3. Adapters
Adapters are concrete implementations of the ports and allow external systems to communicate with the application core. Adapters can include database implementations, REST APIs, or even user interfaces.
Example:
// Adapter for persistence (e.g., JPA Repository)
public class JpaProductRepository implements ProductRepository {
@Autowired
private JpaRepository<Product, Long> jpaRepository;
@Override
public Product findById(Long id) {
return jpaRepository.findById(id).orElse(null);
}
@Override
public void save(Product product) {
jpaRepository.save(product);
}
}
Here, the JpaProductRepository
acts as an adapter for the ProductRepository
port, allowing the application to use JPA for persistence.
Full Example of Hexagonal Architecture
Let’s bring it all together by implementing a small system that handles products, where we’ll apply Hexagonal Architecture.
1. Domain Model (Core Business Logic)
public class Product {
private Long id;
private String name;
private double price;
public void applyDiscount(double percentage) {
if (percentage > 0 && percentage <= 100) {
this.price = this.price - (this.price * (percentage / 100));
}
}
// Getters and Setters
}
2. Inbound Port (Product Service Interface)
public interface ProductService {
Product getProductById(Long id);
void applyDiscountToProduct(Long id, double percentage);
}
3. Outbound Port (Repository Interface)
public interface ProductRepository {
Product findById(Long id);
void save(Product product);
}
4. Inbound Adapter (REST Controller)
@RestController
@RequestMapping("/products")
public class ProductController {
private final ProductService productService;
@Autowired
public ProductController(ProductService productService) {
this.productService = productService;
}
@GetMapping("/{id}")
public Product getProductById(@PathVariable Long id) {
return productService.getProductById(id);
}
@PostMapping("/{id}/discount")
public void applyDiscount(@PathVariable Long id, @RequestParam double discount) {
productService.applyDiscountToProduct(id, discount);
}
}
5. Outbound Adapter (JPA Repository Implementation)
@Repository
public class JpaProductRepository implements ProductRepository {
@Autowired
private JpaRepository<Product, Long> jpaRepository;
@Override
public Product findById(Long id) {
return jpaRepository.findById(id).orElse(null);
}
@Override
public void save(Product product) {
jpaRepository.save(product);
}
}
6. Service Implementation (Application Logic)
@Service
public class ProductServiceImpl implements ProductService {
private final ProductRepository productRepository;
@Autowired
public ProductServiceImpl(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Override
public Product getProductById(Long id) {
return productRepository.findById(id);
}
@Override
public void applyDiscountToProduct(Long id, double percentage) {
Product product = productRepository.findById(id);
if (product != null) {
product.applyDiscount(percentage);
productRepository.save(product);
}
}
}
Advantages of Hexagonal Architecture
- Testability: Since the business logic is decoupled from external systems, it becomes easier to write unit tests. You can mock the adapters and test the core logic in isolation.
- Flexibility: Changing external systems (like databases, messaging systems, or APIs) doesn’t affect the core logic, as only the adapters need to be updated.
- Maintainability: The clear separation of concerns simplifies understanding and modifying the system as it evolves.
- Scalability: New features can be added by introducing new adapters without changing the core business logic.
Conclusion
Hexagonal Architecture is a powerful design pattern that promotes separation of concerns, making applications more maintainable, testable, and adaptable to change. By focusing on decoupling the core business logic from external systems through ports and adapters, you can create systems that are easier to extend and modify.
By using this approach, you can avoid common pitfalls associated with tightly coupled architectures and build systems that are more robust and future-proof.