Skip to content

Instantly share code, notes, and snippets.

@yangpeng-chn
Last active May 11, 2025 16:13
Show Gist options
  • Save yangpeng-chn/3ecbb76e6af6a4750cb384a9d8e14636 to your computer and use it in GitHub Desktop.
Save yangpeng-chn/3ecbb76e6af6a4750cb384a9d8e14636 to your computer and use it in GitHub Desktop.

Hexagonal Architecture in Practice: Building Maintainable Systems in Java

1. Introduction

As applications scale, the need to separate concerns and ensure testability, maintainability, and flexibility becomes critical. Hexagonal Architecture (also called Ports and Adapters) provides a framework for cleanly structuring applications around core logic, enabling better testability and decoupling from technologies like databases and HTTP.

2. Why Hexagonal Architecture?

Problems with Layered Architecture

  • Tight coupling between layers (especially business logic and persistence)
  • Difficult to test core logic in isolation
  • Changing frameworks (e.g., from REST to gRPC) involves deep changes

Goals of Hexagonal Architecture

  • Isolate the domain logic from the infrastructure
  • Allow multiple interfaces (web, CLI, batch jobs, etc.) to drive the app
  • Enable easy swapping of persistence and external systems

3. Core Components

hexagonal-architecture-diagram.svg

Hexagonal Architecture

Application Core (Center Hexagon):

  • Contains all business logic and domain models
  • Completely independent of external concerns
  • The most stable part of the application

Ports:

  • Represented by small rectangles on the edges of the hexagon
  • Act as interfaces that define how the domain interacts with the outside world, that is why it is at the boundary between Application Core and Adapters.
  • Two types:
    • Input (Driving) Ports: On the left side, define how external actors can use the application. Interfaces through which external actors interact with the application (e.g., REST controller calls a use case, where the use case here is the ports).
    • Ouput (Driven) Ports: On the right side, define how the application uses external systems, which are interfaces the application uses to talk to external systems (e.g., repositories)

Adapters:

  • Connect the outside world to the ports
  • Two types:
    • Input (Driving) Adapters: Implement input ports, transform external requests into a format the domain can understand
    • Output (Driven) Adapters: Implement output ports (interfaces) defined by the domain to communicate with external systems

4. Implementing Hexagonal Architecture

Let's implement a Todo App in Java with Hexagonal Architecture.

src/main/java/com/example/todo/
├── domain/
│   └── Todo.java                         # Domain model (pure logic, no annotations)
├── application/
│   ├── port/
│   │   ├── in/                           # Input ports (use case interfaces)
│   │   │   └── TodoUseCase.java
│   │   └── out/                          # Output ports (e.g., repositories)
│   │       └── TodoRepositoryPort.java
│   └── service/
│       └── TodoService.java              # Use case implementation
├── adapter/
│   ├── in/
│   │   └── web/
│   │       └── TodoController.java       # Driving adapter (REST)
│   └── out/
│       └── persistence/
│           ├── TodoEntity.java          # JPA entity (maps to DB)
│           ├── TodoRepository.java      # Spring Data JPA interface
│           └── TodoPersistenceAdapter.java  # Implements the output port
├── config/
│   └── BeanConfiguration.java            # Spring config (wiring)

Here is the class diagram:

classDiagram
    class Todo {
        - String id
        - String title
        - boolean completed
    }

    class TodoUseCase {
        <<interface>>
        + create(String): Todo
        + getById(String): Todo
        + getAll(): List~Todo~
        + update(String, String, boolean): Todo
        + delete(String): void
    }

    class TodoRepositoryPort {
        <<interface>>
        + save(Todo): Todo
        + findById(String): Optional~Todo~
        + findAll(): List~Todo~
        + deleteById(String): void
    }

    class TodoService {
        - TodoRepositoryPort repository
        + create()
        + getById()
        + getAll()
        + update()
        + delete()
    }

    class TodoController {
        - TodoUseCase useCase
    }

    class TodoPersistenceAdapter {
        - TodoRepository todoRepository
    }

    class TodoRepository {
        <<interface>>
    }

    class TodoEntity {
        - String id
        - String title
        - boolean completed
    }

    TodoService ..|> TodoUseCase : implements
    TodoService --> TodoRepositoryPort : uses

    TodoPersistenceAdapter ..|> TodoRepositoryPort : implements
    TodoPersistenceAdapter --> TodoRepository : delegates to

    Todo --> TodoEntity : maps to

    TodoController --> TodoUseCase : uses
Loading
Todo Classes Diagram

Let's deep dive into the components:

Domain Model

Its main responsibility is to model the business rules. In this example, it is the Todo class. The domain model should not depend on any specific technology, so it usually has no annotations.

public class Todo {
    private final String id;
    private String title;
    private boolean completed;

    // constructors, getters, setters
}

Application Layer

Use Case Interface (input ports)

As mentioned earlier, input ports (here the use cases) should define what our application is available to the external, the typical case here are the CURD operations.

public interface TodoUseCase {
    Todo create(String title);
    Todo getById(String id);
    List<Todo> getAll();
    Todo update(String id, String title, boolean completed);
    void delete(String id);
}

Persistence Interface (output ports)

We need to store the data of the Todo to external data stores, and we define the output ports as an interface that includes the basic CURD operations we need.

public interface TodoRepositoryPort {
    Todo save(Todo todo);
    Optional<Todo> findById(String id);
    List<Todo> findAll();
    void deleteById(String id);
}

Service Layer (implements use case)

This is the part that has all the business logic.

  1. It implements the use case
  2. It has the output port interface as a member.
public class TodoService implements TodoUseCase {
    private final TodoRepositoryPort repository;

    public TodoService(TodoRepositoryPort repository) {
        this.repository = repository;
    }

    // Implement all methods by delegating to repository
}

Adapters (Driving and Driven)

Driving Adapter (Controller)

The controller has the use case member, which is implemented by the service layer.

@RestController
@RequestMapping("/todos")
public class TodoController {
    private final TodoUseCase useCase;

    public TodoController(TodoUseCase useCase) {
        this.useCase = useCase;
    }

    @PostMapping
    public Todo create(@RequestParam String title) {
        return useCase.create(title);
    }

    // Other endpoints...
}

Driven Adapter (Persistence)

To implement the adapter, we need several other classes:

  1. Entity: it represents the data stored in the external data stores, RDS, MongoDB etc.
  2. Repository interface: it extends some repository in some framework, e.g. JpaRepository in Spring Data.
@Entity
public class TodoEntity {
    @Id
    private String id;
    private String title;
    private boolean completed;

    // Getters, Setters, Constructors
}
public interface TodoRepository extends JpaRepository<TodoEntity, String> {}

Alternately, we could define the repository using Mongo.

public interface TodoRepository extends MongoRepository<TodoEntity, String> { }

Now let's see how to create the adapter implementing the output ports with the help of the Repository.

@Component
public class TodoPersistenceAdapter implements TodoRepositoryPort {
    private final TodoRepository todoRepository;

    @Override
    public Todo save(Todo todo) {
        TodoEntity entity = new TodoEntity(todo.getId(), todo.getTitle(), todo.isCompleted());
        todoRepository.save(entity);
        return todo;
    }

    // Implement all methods by delegating to the repository
}

Recap

Role Layer Type Interacts with Direction Term
Controller Adapter Calls input port Driving Driving Adapter
TodoUseCase (interface) Input Port Exposed by core Driven Primary Port
Service (implements input port) Application Core Implements use cases Driven Business Logic
TodoRepositoryPort Output Port Called by core Driving Secondary Port
Persistence Adapter Adapter Implements output port Driven Driven Adapter

5. Dependency Inversion Principle (DIP)

DIP in Hexagonal Architecture

The architecture is a practical implementation of the Dependency Inversion Principle:

High-level modules should not depend on low-level modules. Instead, both should depend on abstractions (interfaces or abstract classes).

Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.

We have seen how we applied it in our example above:

  • Controller (High-level module) depends on TodoUseCase (low-level) (not service implementation)
  • Service depends on TodoRepositoryPort (not actual repository)

6. Comparison with Other Architectures

Architecture Focus Core-Infra Separation Testability Technology Agnostic
Layered Separation by concerns Weak Moderate No
Clean Use cases and boundaries Strong Strong Yes
Hexagonal Ports and adapters Strong Strong Yes

7. When to Use (and Avoid) Hexagonal Architecture

Use When:

  • Building complex or long-lived systems
  • Need for testability and maintainability
  • Multiple interfaces (UI, API, CLI)

Avoid When:

  • Very small apps or scripts
  • MVPs that won't evolve

8. Testing in Hexagonal Architecture

Benefits

  • Easy to unit test use cases
  • Mock output ports to isolate core logic

Strategy

  • Unit test the domain and service with mocked ports
  • Integration test adapters with real implementations

9. Best Practices

  • Keep the domain free of annotations and frameworks
  • Always use interfaces for ports
  • Keep adapters thin and focused
  • Prefer constructor injection
  • Avoid leaking framework concepts into core

10. Conclusion

Hexagonal Architecture is a powerful pattern for structuring maintainable, testable, and framework-agnostic systems. By cleanly separating core business logic from infrastructure, it allows developers to evolve and adapt systems more easily. The ports and adapters pattern also aligns with SOLID principles—particularly the Dependency Inversion Principle—making it a go-to architecture for serious Java backend development.

If you're building anything beyond a trivial app, consider starting with Hexagonal Architecture or refactoring towards it. The up-front investment in structure pays off in long-term flexibility and resilience.

References

  1. https://romanglushach.medium.com/hexagonal-architecture-the-secret-to-scalable-and-maintainable-code-for-modern-software-d345fdb47347
  2. https://jivimberg.io/blog/2020/02/01/hexagonal-architecture-on-spring-boot/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment