Hexagonal Architecture in Practice
Introduction
Proposed by Alistair Cockburn, the Hexagonal Architecture (Ports & Adapter) promises something every developer wants: code that survives the test of time.
In this article, you'll see how I implemented this approach at Sauron, a customer registration and evaluation service, and how you can apply the same principles to your projects.
What problem does Hexagonal Architecture solve?
How many times have you seen (or written) code similar to this?
@Service
public class CustomerService {
@Autowired
private CustomerRepository repository; // JPA leaking everywhere
public Customer createCustomer(CustomerDTO dto) {
Customer customer = new Customer();
customer.setName(dto.getName());
// ... mais 50 linhas misturando validação, negócio e persistência
}
}
Problems:
- High coupling with frameworks (Spring, JPA, etc.).
- Tests are impossible without using the Spring context.
- Changes to the database? You'll need to refactor half of system.
- Business rules lost between a lot of annotations and configurations.
The Hexagonal Architecture can solve this with a clear separation of responsibilities.
The layered structure
At Sauron project, the structure is divided into three main layers:
src/main/java/com/github/thrsouza/sauron/
├── domain/ # Domain Layer (core)
├── application/ # Application Layer (use cases)
└── infrastructure/ # Infrastructure Layer (adapters)
Just to be clear:
- This is how I like to organize my code.
- Software architecture is not about how you should organize your packages and directories names.
1. Domain Layer: The heart of the system
The domain is your system core. Here you should to create the business rules, without any external dependencies.
package com.github.thrsouza.sauron.domain.customer;
public class Customer extends AggregateRoot {
private final UUID id;
private final String document;
private final String name;
private final String email;
private CustomerStatus status;
public static Customer create(String document, String name, String email) {
UUID id = UUID.randomUUID();
Customer customer = new Customer(id, document, name, email, CustomerStatus.PENDING);
customer.recordDomainEvent(new CustomerCreated(customer.id()));
return customer;
}
public void approve() {
if (this.status != CustomerStatus.PENDING) {
throw new IllegalArgumentException("Customer status is not pending");
}
this.status = CustomerStatus.APPROVED;
this.recordDomainEvent(new CustomerApproved(this.id()));
}
}
Key features:
- Zero framework annotations (@Entity, @Service, etc.).
- Clear and testable business logic.
- Protected invariants (it is not possible to approve non-pending customers).
- Auto descriptive factory methods (Customer.create()).
2. Application Layer: Flow orchestration
The application layer contains use cases that orchestrate the domain's business rules.
package com.github.thrsouza.sauron.application.customer;
public class CreateCustomerUseCase {
private final CustomerRepository customerRepository;
private final DomainEventPublisher domainEventPublisher;
public CreateCustomerUseCase(CustomerRepository customerRepository,
DomainEventPublisher domainEventPublisher) {
this.customerRepository = customerRepository;
this.domainEventPublisher = domainEventPublisher;
}
public Output handle(Input input) {
// Check if the customer already exists (idempotency)
Optional<Customer> existingCustomer =
customerRepository.findByDocument(input.document());
if (existingCustomer.isPresent()) {
return new Output(existingCustomer.get().id());
}
// Create new customer
Customer customer = Customer.create(
input.document(),
input.name(),
input.email()
);
// Persist and publish events
customerRepository.save(customer);
domainEventPublisher.publishAll(customer.pullDomainEvents());
return new Output(customer.id());
}
public record Input(String document, String name, String email) {}
public record Output(UUID id) {}
}
Key features:
- It's a pre POJO (Plain Old Java Object).
- Explicit dependencies via the constructor.
- Input/Output with Records (immutable).
- Domain, repository and events orchestration.
3. Infrastructure Layer: Adapters for the outside world
The infrastructure implements the interfaces defined by the domain.
Example: Repository Adapter
@Component
public class CustomerRepositoryAdapter implements CustomerRepository {
private final CustomerJpaRepository jpaRepository;
private final CustomerJpaMapper mapper;
@Override
public Optional<Customer> findById(UUID id) {
return jpaRepository.findById(id)
.map(mapper::toDomain);
}
@Override
public void save(Customer customer) {
CustomerJpaEntity entity = mapper.toEntity(customer);
jpaRepository.save(entity);
}
}
The Secret: Dependency inversion.
The CustomerRepository is in the domain, but the implementation is in the infrastructure.
// Domain layer (defines the contract)
package com.github.thrsouza.sauron.domain.customer;
public interface CustomerRepository {
Optional<Customer> findById(UUID id);
void save(Customer customer);
}
// Infrastructure layer (implements the contract)
package com.github.thrsouza.sauron.infrastructure.persistence.jpa.adapter;
@Component
public class CustomerRepositoryAdapter implements CustomerRepository {
// implementation
}
It is Dependency Inversion Principle (DIP) in action: high-level modules (domain) do not depend on low-level modules (infrastructure). Both depend on abstractions.
Manual setup: Full control
In Sauron, the use cases are configured manually via @Configuration:
@Configuration
public class UseCaseConfig {
private final DomainEventPublisher domainEventPublisher;
private final CustomerRepository customerRepository;
@Bean
public CreateCustomerUseCase createCustomerUseCase() {
return new CreateCustomerUseCase(customerRepository, domainEventPublisher);
}
}
Why not use @Service annotation on use cases?
- The application layer should be independent of Spring.
- Unit tests easy (without Spring context).
- Make the dependencies explicit.
- Full control over object creation.
Real benefits
1. Testability
Domain testing is really simple:
@Test
void shouldApproveCustomer() {
Customer customer = Customer.create("12345678900", "John", "john@example.com");
customer.approve();
assertEquals(CustomerStatus.APPROVED, customer.status());
}
No dependencies on Spring, database, or Kafka. Fast and reliable testing.
2. Framework Replacements
Do you want to replace JPA with MongoDB? Simple create a new adapter:
@Component
public class CustomerMongoRepositoryAdapter implements CustomerRepository {
// New implementation
}
The domain and application still untouched.
3. Delivery independence
The same domain can be exposed via:
- REST API (Spring MVC)
- GraphQL
- gRPC
- CLI
- Messaging (Kafka, RabbitMQ)
Consider creating new adapters. That's all you need!
Common mistakes
Anemic Domain Model:
// WRONG: Only getters and setters
public class Customer {
private CustomerStatus status;
public void setStatus(CustomerStatus status) {
this.status = status; // No validation!
}
}
// CORRECT: Behavior and validation
public class Customer {
public void approve() {
if (this.status != CustomerStatus.PENDING) {
throw new IllegalArgumentException("Customer status is not pending");
}
this.status = CustomerStatus.APPROVED;
}
}
Infrastructure leaking:
// WRONG: JPA leaking to the domain
public interface CustomerRepository {
Page<Customer> findAll(Pageable pageable); // Pageable is from Spring Data!
}
// CORRECT: Domain abstractions
public interface CustomerRepository {
List<Customer> findAll(int page, int size);
}
When not use Hexagonal Architecture?
Hexagonal architecture is not a silver bullet. You should avoid for all problems. You should avoid when:
- Prototypes or disposable MVPs
- Simple CRUD without business rules
- Scripts or internal tools
- Projects with tight deadlines and inexperienced teams
For these cases, a traditional MVC should be enough.
Conclusion
Hexagonal Architecture is not just about folder structures or number of layers. It's about responsibilities separation, testability and independence of frameworks.
Remember: Architecture is directly related to trade-offs. Hexagonal Architecture adds initial complexity in exchange for long-term sustainability. Choose wisely.