Hexagonal Architecture: The Key to Scalable and Maintainable Microservices

 

Introduction

In a microservice environment, usually requirements head to a direction wherein client expect to have a plug-and-play feature with respect to the microservices being installed or upgrade. In one of our project, we did face such requirements from client, wherein they wanted to add more features to the product meaning they wanted to bring in more microservices but the catch here was that it should be easily configurable and more importantly should easily blend in with the overall product have no much need to update any other microservice. To tackle such a requirement, we then studied about the available design architectures and then decided to go with Hexagonal architecture, also known as the Ports and Adapters. This approach focuses on creating a flexible, maintainable, and testable system by separating core business logic from external concerns.

In this blog, we’ll explore Hexagonal Architecture in detail, including its principles, benefits, and how to implement it using a practical example.


What is Hexagonal Architecture ?

Hexagonal Architecture, introduced by Alistair Cockburn, emphasizes the separation of the application's core logic from its external interactions. This separation is achieved through a well-defined interface between the core logic and external systems, such as databases, web services, or user interfaces.

The architecture is called "hexagonal" because it visualizes the system as a central core surrounded by different interfaces or "ports" that connect to various "adapters."


Key Concepts

  1. Core Logic (Application Domain): The heart of the application containing business rules, domain models, and use cases.
  2. Ports: Interfaces through which the core logic interacts with the outside world. There are inbound ports (for receiving requests which could be a Rest API, a SOAP request or an event/message and so on) and outbound ports (for making requests to external systems could be persisting data inside different databases or writing to a file or producing an event  or forwarding it to a different API and so on).
  3. Adapters: Implementations of the ports. Adapters convert between the core logic's expected input/output and the format used by external systems. Basically adapters uses the ports to interact with the core logic. There wont be any direct call from Adapters to Core Logic, it will always be via Ports.

Note: Ports are the way through which the communication to and from the core can be done. One can visualise ports as interfaces which define how the communication with the core can be done and the adapters will then create an instance of the ports and use them to initiate core logic. For example, when it is REST API's, then the same port can be called and when we switch to event's, even then the same ports should be called, thus irrespective of the way the messages/calls are coming in, the way to communicate with the core business logic remains same, thus making core business logic independent of the way the inbound/outbound calls/messages/events are handled. This provides an abstraction layer which confines business logic with the way calls are intercepted.


Benefits of Hexagonal Architecture

  1. Separation of Concerns: By isolating business logic from external systems, changes in one area (e.g., database) don’t impact the core logic.
  2. Testability: The core logic can be tested independently of external systems by mocking adapters.
  3. Flexibility: Easier to replace or modify external systems (e.g., swapping out databases or integrating new services).

Implementing Hexagonal Architecture

Let’s dive into a practical example of building a microservice using Hexagonal Architecture with Spring Boot.

1. Define the Core Domain

First, create a Spring Boot project with the necessary dependencies. Define your domain model and business logic.

java
// src/main/java/com/example/core/domain/User.java package com.example.core.domain; public class User { private Long id; private String name; // Getters and Setters } // src/main/java/com/example/core/usecase/UserService.java package com.example.core.usecase; import com.example.core.domain.User; public interface UserService { User createUser(User user); User getUser(Long id); }

2. Define Ports

Create interfaces (ports) for the inbound and outbound operations.

java
// src/main/java/com/example/core/port/in/UserCommandPort.java package com.example.core.port.in; import com.example.core.domain.User; public interface UserCommandPort { User createUser(User user); } // src/main/java/com/example/core/port/in/UserQueryPort.java package com.example.core.port.in; import com.example.core.domain.User; public interface UserQueryPort { User getUser(Long id); }

3. Implement Adapters

Create implementations for inbound and outbound ports.

Inbound Adapters (Controllers):

java
// src/main/java/com/example/adapter/in/web/UserController.java package com.example.adapter.in.web; import com.example.core.domain.User; import com.example.core.port.in.UserCommandPort; import com.example.core.port.in.UserQueryPort; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/users") public class UserController { private final UserCommandPort userCommandPort; private final UserQueryPort userQueryPort; public UserController(UserCommandPort userCommandPort, UserQueryPort userQueryPort) { this.userCommandPort = userCommandPort; this.userQueryPort = userQueryPort; } @PostMapping public User createUser(@RequestBody User user) { return userCommandPort.createUser(user); } @GetMapping("/{id}") public User getUser(@PathVariable Long id) { return userQueryPort.getUser(id); } }

Outbound Adapters (Repositories):

java
// src/main/java/com/example/adapter/out/persistence/UserRepository.java package com.example.adapter.out.persistence; import com.example.core.domain.User; import com.example.core.port.in.UserCommandPort; import com.example.core.port.in.UserQueryPort; import org.springframework.stereotype.Repository; @Repository public class UserRepository implements UserCommandPort, UserQueryPort { // Assuming an in-memory store for simplicity private final Map<Long, User> store = new HashMap<>(); @Override public User createUser(User user) { store.put(user.getId(), user); return user; } @Override public User getUser(Long id) { return store.get(id); } }

4. Wiring Up

Finally, configure the application with the necessary beans.

java
// src/main/java/com/example/Application.java package com.example; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }

5. Testing

With Hexagonal Architecture, you can test the core logic independently of the adapters.

Unit Test Example:

java
// src/test/java/com/example/core/usecase/UserServiceTest.java package com.example.core.usecase; import com.example.core.domain.User; import com.example.core.port.in.UserCommandPort; import com.example.core.port.in.UserQueryPort; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import static org.junit.jupiter.api.Assertions.assertEquals; public class UserServiceTest { @Test void testCreateUser() { UserCommandPort commandPort = Mockito.mock(UserCommandPort.class); UserQueryPort queryPort = Mockito.mock(UserQueryPort.class); UserService userService = new UserServiceImpl(commandPort, queryPort); User user = new User(); user.setId(1L); user.setName("John Doe"); Mockito.when(commandPort.createUser(user)).thenReturn(user); assertEquals(user, userService.createUser(user)); } }

Conclusion

Hexagonal Architecture is a powerful pattern for designing microservices that are flexible, testable, and maintainable. By separating core business logic from external systems through well-defined ports and adapters, it allows for better isolation, easier testing, and more straightforward system evolution.

This blog demonstrated how to implement Hexagonal Architecture using a Spring Boot application. With this approach, you can build robust microservices capable of adapting to changes in external systems with minimal impact on core functionality.

Feel free to experiment with this architecture in your own projects and explore how it can benefit your development process!


Further Reading

Post a Comment

0 Comments