Hexagonal Architecture?
You may have heard the terms "Hexagonal Architecture" or "Ports and Adapters", coined by Alistair Cockburn, in some form or another. It's an architectural design pattern that places the business rules at its core. The idea is that the business rules are what defines a company, not technical details like what kind of presentation or database technology is used. But how do we get from a classic layered architecture to Hexagonal Architecture?
Layered Architecture in a Nutshell
In a classic layered architecture, we typically find the following layers: Presentation --> Application --> Data Access. Upper layers depend on lower layers. All the layers however, depend on the data access layer. This means that if we change the data access technology, say from SQL to an external system, we need to adapt the code of layers above, even though they did not change in functionality.
@RestController
class RestCustomerController {
CustomerService service
@PostMapping("/customer/name")
CustomerDbEntity changeCustomerName(CustomerDbEntity dbEntity) {
return service.changeCustomerName(dbEntity);
}
}
@Service
class CustomerService {
SqlDbClient client
public CustomerDbEntity changeCustomerName(CustomerDbEntity input) {
CustomerDbEntity entity = client.execute(
"select * from customer where id="+input.id);
CustomerDbEntity result = applySomeBusinessRules(entity, input);
return client.execute(
"update customer set name =" +result.name +" where
id="+result.id);
}
}
From Layered to Hexagonal Architecture in 2 Steps
Now, let's see how we can transition from this layered architecture to a hexagonal architecture.
Step 1 - Invert the Dependencies of the Application Service to the Data Access layer
The first step is to make the data access layer depend on the application layer, not the other way around. We can achieve this by introducing an interface that only uses application-specific data structures in its signature for CRUD operations. For that, a Customer class may be created, which could typically be a domain class that can also execute critical business rules. The important idea here is that the signature of the interface does not contain any CustomerDbEntity. The interface is application-layer specific. It's the first output port that is used by the CustomerService to load and store Customers. On the other hand, SqlCustomerRepository needs to implement this interface. It takes a Customer object, maps it to the CustomerDbEntity, invokes the db client, and potentially maps the CustomerDbEntity back to a Customer. Thus, it adapts the DB data structure from and to the application-specific Customer data structure. It's an adapter. Also, the service does not use CustomerDbEntity in its public method signatures anymore, but only simple application-layer-specific DTOs like CustomerData that get passed to and returned from the service. In pseudocode:
interface CustomerRepository {
Customer getById(String id);
Customer update(Customer customer);
}
@Service
class CustomerService {
CustomerRepository customers;
public CustomerData changeCustomerName(NameData input) {
Customer customer = customers.getById(input.id);
customer.changeNameTo(input.name);
Customer updatedCustomer = customers.update(customer);
return toData(updatedCustomer);
}
}
@Repository
class SqlCustomerRepository implements CustomerRepository {
SqlDbClient client;
Customer getById(String id) {
CustomerDbEntity entity = client.execute(
"select * from customer where id="+input.id);
return toCustomer(entity);
}
public Customer update(Customer input) {
CustomerDbEntity entity = client.execute(
"update customer set name =" +result.name +" where
id="+result.id);
return toCustomer(entity);
}
}
We could now easily implement a fake CustomerRepository for testing purposes:
class InMemoryCustomerRepository implements CustomerRepository {
Map<String, Customer> customers;
Customer getById(String id) { return customers.get(id); }
Customer update(Customer customer) {
customers.put(customer);
return customer;
}
}
Step 2 - Segregate the Public Methods of the Application Service into Separate Interfaces
The second step involves hiding away how the different public methods of a generic application service are implemented from the presentation layer. This could be useful if we wanted to split a larger service into smaller use cases. By adding an interface for every public use case method that the CustomerService provides, and letting it implement them all, the RestCustomerController gets more fine-granular methods to execute. The controller may or may not use an own DTO instead of the data structures provided by the application layer in the CustomerService's public methods. That way, it may evolve independently from the application layer, for example if not all the fields need to be returned or they should be formatted already on the server, etc. These interfaces correspond to input ports, and the controller is a driving adapter that maps incoming data to the input port's data requirements, executes the functions of these ports, and maps data back to the caller's data format. In pseudocode:
interface ChangeCustomerName {
CustomerData changeNameTo(NameData name);
}
interface ChangeCustomerAddress {
CustomerData changeAddressTo(AddressData address);
}
interface ChangeCustomerProfile {
CustomerData changeProfileTo(CustomerProfileData profile);
}
@RestController
class RestCustomerController {
ChangeCustomerName changeCustomerName;
ChangeCustomerAddress changeCustomerAddress;
ChangeCustomerProfile changeCustomerProfile;
@PostMapping("/customer/name")
CustomerDto changeCustomerName(NameDto dto) {
NameData data = toData(dto);
CustomerData customer = changeCustomerName.changeNameTo(data);
return toDto(customer);
}
@PostMapping("/customer/address")
CustomerDto changeCustomerAddress(AddressDto dto) {
AddressData data = toData(dto);
CustomerData customer = changeCustomerAddress.changeAddressTo(data);
return toDto(customer);
}
@PostMapping("/customer/profile")
CustomerDto changeCustomerProfile(ProfileDto dto) {
ProfileData data = toData(dto);
CustomerData customer = changeCustomerProfile.changeProfileTo(data);
return toDto(customer);
}
}
@Service
class CustomerService
implements
ChangeCustomerName, ChangeCustomerAddress, ChangeCustomerProfile {
@Override
CustomerData changeNameTo(NameData name){...}
@Override
CustomerData changeAddressTo(AddressData address) {...}
@Override
CustomerData changeProfileTo(CustomerProfileData profile) {...}
}
That's it, basically
The controller adapter now uses any of the three input ports of the application layer and does not know of the CustomerService anymore, which implements the 3 port interfaces instead. Similarly, the CustomerService only uses output ports and does not know of the SqlCustomerRepository and only interacts with the output port interface CustomerRepository methods. There is even a Customer class that can be used to implement critical business rules on, which could be stored within an own "domain" layer. The domain layer is not strictly Ports & Adapters anymore but actually Domain-Driven Design. Data access and presentation now depend on the application layer. All dependencies (arrows) point towards this application layer. This is why hexagonal architecture is a business-centric architecture. The different data structures may feel like an overkill for simple applications - they are. Their value comes especially in complex architectures, where presentation, application, and data access layers change independently, often. They are not required by Ports & Adapters either, as long as the application does not depend on any data structure from the outside world. It can only know internal data structures.
What do you think about this architectural design pattern? Have you used it? To what success? What downsides do you see?
What approach do you use when a team is stuck in using ORMs and shows resistance towards moving away from ORMs?
Great answer. I also found that a mapping library like MapStruct can reduce the boilerplate that feeds resistance.