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.
- 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
- 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
Hexagonal Architecture
- Contains all business logic and domain models
- Completely independent of external concerns
- The most stable part of the application
- 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)
- 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
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
Todo Classes Diagram
Let's deep dive into the components:
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
}
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);
}
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);
}
This is the part that has all the business logic.
- It implements the use case
- 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
}
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...
}
To implement the adapter, we need several other classes:
- Entity: it represents the data stored in the external data stores, RDS, MongoDB etc.
- 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
}
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 |
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)
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 |
- Building complex or long-lived systems
- Need for testability and maintainability
- Multiple interfaces (UI, API, CLI)
- Very small apps or scripts
- MVPs that won't evolve
- Easy to unit test use cases
- Mock output ports to isolate core logic
- Unit test the domain and service with mocked ports
- Integration test adapters with real implementations
- 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
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.