DEV Community

Cover image for Hexagonal Architecture: Building Maintainable and Testable Applications
A. S. M. Tarek
A. S. M. Tarek

Posted on

Hexagonal Architecture: Building Maintainable and Testable Applications

In today’s world of increasingly complex software systems, designing applications that are modular, testable, and easy to maintain is more crucial than ever. Traditional layered architectures often create tight coupling, making testing painful and evolution risky. Enter Hexagonal Architecture (aka Ports and Adapters)- a pattern that flips dependency management on its head. Created by Alistair Cockburn, it isolates your core business logic from external chaos. Let’s dissect how it works and why it’s a game-changer.

What is Hexagonal Architecture?

Hexagonal Architecture is a design approach that organizes an application into a central core surrounded by ports and adapters. The "hexagonal" name comes from visualizing the architecture as a hexagon, where the core business logic sits at the center, and the edges represent interfaces (ports) that connect to external systems via adapters. This pattern aims to:

  • Isolate business logic: Keep the core logic independent of external systems like databases, APIs, or user interfaces.
  • Enable flexibility: Make components exchangeable by defining clear boundaries.
  • Simplify testing: Enable straightforward mocking of external dependencies during testing.

The architecture achieves these goals by defining ports (interfaces that specify how the core interacts with the outside world) and adapters (concrete implementations that connect ports to specific technologies or systems).

Core Concepts: Ports and Adapters

To understand Hexagonal Architecture, let's break down its key components:

  1. Core (Business Logic): The core contains the application's business logic or use cases. It represents the "what" of the application—its primary functionality. This layer is technology-agnostic, meaning it doesn't know about databases, APIs, or UI frameworks. The core is written in pure domain terms, focusing on solving the business problem.

  2. Ports: Ports are interfaces that define a contract for how the core communicates with the outside world. They act as gateways, specifying what the core needs or provides without dictating how it’s implemented. Ports come in two flavors:

  3. Inbound Ports: Define how external systems can interact with the core. For example, a UserService interface that exposes methods like createUser or getUser.

  4. Outbound Ports: Define what the core needs from external systems. For example, a UserRepository interface that declares methods like saveUser or findUserById.

  5. Adapters: Adapters are concrete implementations of ports. They bridge the gap between the core and external systems by translating the port’s interface into specific technologies. Adapters also come in two types:

  6. Inbound Adapters: Handle incoming requests from external systems (e.g., a REST API controller or a CLI) and invoke the core through inbound ports.

  7. Outbound Adapters: Implement outbound ports to interact with external systems (e.g., a database, message queue, or third-party API).

By using ports and adapters, the core remains decoupled from external systems, making it easy to swap out one adapter for another (e.g., replacing a MySQL database with MongoDB).

Why Use Hexagonal Architecture?

Hexagonal Architecture offers several benefits that make it a compelling choice for modern software systems:

  • Loose Coupling: The core is isolated from external systems, reducing dependencies and making the system more modular.
  • Testability: Ports allow easy mocking of external systems, enabling unit tests to focus on business logic without requiring real databases or APIs.
  • Flexibility: Adapters can be swapped or extended without changing the core, supporting multiple technologies or deployment scenarios.
  • Maintainability: Clear separation of concerns makes the codebase easier to understand and evolve.
  • Scalability: The architecture supports adding new adapters (e.g., new UI or storage systems) with minimal effort.

A Practical Example: Building a User Management System

Let’s walk through a simple example of a user management system using Hexagonal Architecture. We’ll implement it in Java, but the concepts apply to any language.

Scenario: We want to build a system that allows creating and retrieving user information. The system should support multiple persistence mechanisms (e.g., in-memory and database) and expose the functionality via a REST API.

Step 1: Define the Core (Business Logic)

The core contains the business logic for managing users. We’ll create a User entity and a use case for handling user operations.

// User.java
public class User {
    private String id;
    private String name;
    private String email;

    public User(String id, String name, String email) {
        this.id = id;
        this.name = name;
        this.email = email;
    }

    // Getters and setters
}
Enter fullscreen mode Exit fullscreen mode
// UserUseCase.java (Business Logic)
public class UserUseCase {
    private final UserRepository userRepository;

    public UserUseCase(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public User createUser(String name, String email) {
        String id = UUID.randomUUID().toString();
        User user = new User(id, name, email);
        userRepository.saveUser(user);
        return user;
    }

    public User getUser(String id) {
        return userRepository.findUserById(id)
                .orElseThrow(() -> new RuntimeException("User not found"));
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Define Ports

We’ll create two ports: an outbound port for persistence and an inbound port for the use case.

// UserRepository.java (Outbound Port)
public interface UserRepository {
    void saveUser(User user);
    Optional<User> findUserById(String id);
}
Enter fullscreen mode Exit fullscreen mode
// UserService.java (Inbound Port)
public interface UserService {
    User createUser(String name, String email);
    User getUser(String id);
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Implement Adapters

Now, we’ll create adapters to connect the ports to specific technologies.

Outbound Adapter: In-Memory Repository

// InMemoryUserRepository.java
public class InMemoryUserRepository implements UserRepository {
    private final Map<String, User> users = new HashMap<>();

    @Override
    public void saveUser(User user) {
        users.put(user.getId(), user);
    }

    @Override
    public Optional<User> findUserById(String id) {
        return Optional.ofNullable(users.get(id));
    }
}
Enter fullscreen mode Exit fullscreen mode

Inbound Adapter: REST Controller

Using a framework like Spring Boot, we can create a REST controller to expose the UserService.

// UserController.java
@RestController
@RequestMapping("/users")
public class UserController {
    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @PostMapping
    public User createUser(@RequestBody UserRequest request) {
        return userService.createUser(request.getName(), request.getEmail());
    }

    @GetMapping("/{id}")
    public User getUser(@PathVariable String id) {
        return userService.getUser(id);
    }
}
Enter fullscreen mode Exit fullscreen mode
// UserRequest.java
public class UserRequest {
    private String name;
    private String email;

    // Getters and setters
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Wiring It Together

We can use dependency injection (e.g., Spring) to wire the components:

// ApplicationConfig.java
@Configuration
public class ApplicationConfig {
    @Bean
    public UserRepository userRepository() {
        return new InMemoryUserRepository();
    }

    @Bean
    public UserService userService(UserRepository userRepository) {
        return new UserUseCase(userRepository);
    }

    @Bean
    public UserController userController(UserService userService) {
        return new UserController(userService);
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Testing

Since the core is isolated, we can easily test the UserUseCase by mocking the UserRepository.

@Test
public void shouldCreateUser() {
    UserRepository mockRepository = mock(UserRepository.class);
    UserUseCase useCase = new UserUseCase(mockRepository);

    User user = useCase.createUser("John Doe", "john@example.com");

    verify(mockRepository).saveUser(user);
    assertNotNull(user.getId());
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Swapping Adapters

To switch to a database (e.g., MongoDB), we only need to create a new UserRepository implementation without changing the core or inbound adapters.

// MongoUserRepository.java
public class MongoUserRepository implements UserRepository {
    private final MongoTemplate mongoTemplate;

    public MongoUserRepository(MongoTemplate mongoTemplate) {
        this.mongoTemplate = mongoTemplate;
    }

    @Override
    public void saveUser(User user) {
        mongoTemplate.save(user);
    }

    @Override
    public Optional<User> findUserById(String id) {
        return Optional.ofNullable(mongoTemplate.findById(id, User.class));
    }
}
Enter fullscreen mode Exit fullscreen mode

Common Use Cases

Hexagonal Architecture shines in scenarios where:

  1. You need to support multiple frontends (e.g., web, mobile, CLI).
  2. You anticipate changing persistence layers (e.g., from SQL to NoSQL).
  3. You want to write comprehensive unit tests without external dependencies.
  4. You’re building microservices that need to integrate with various external systems.

Challenges and Considerations
While Hexagonal Architecture offers many benefits, it’s not without challenges:

  • Increased Complexity: Defining ports and adapters adds overhead, which may be overkill for simple applications.
  • Learning Curve: Teams unfamiliar with the pattern may need time to adapt.
  • Over-Abstraction: Excessive use of interfaces can lead to unnecessary complexity.

To mitigate these, start with a simple implementation and gradually introduce ports and adapters as the application grows.

Conclusion
Hexagonal Architecture is a robust pattern for building flexible, testable, and maintainable software systems. By isolating business logic and using ports and adapters, developers can create applications that are easy to extend and adapt to changing requirements. Whether you’re building a monolithic application or a microservice, this architecture provides a solid foundation for clean, modular design.

If you’re looking to improve your codebase’s structure or prepare for future scalability, give Hexagonal Architecture a try. Start small, experiment with a single use case, and watch how it transforms your approach to software design.

Top comments (0)