Pragmatic Hexagonal Architecture
Balancing Clean Code and Practical Flexibility in Software Design
Introduction
As Software Engineers, our goal is to deliver features while maintaining a clean and understandable codebase. We rely on standards and conventions to build maintainable projects.
At BlaBlaCar, for code organisation, Hexagonal Architecture is often used to enhance the flexibility of our codebases. When it comes to implementing this methodology, we observed variations among the teams.
This article aims to explain the core concepts of Hexagonal Architecture in Software Engineering, then to present a pragmatic approach adopted by some teams at BlaBlaCar.
Finally, we will discuss the balance between strict adherence to architectural principles and practical flexibility.
What is Hexagonal Architecture?
The traditional approach
At the beginning of my career as Backend Software Engineer, I contributed to existing legacy codebases by following the traditional Layered Architecture.
This organisation makes sense, as it follows Spring annotation stereotypes, like @Controller
and @Service
for instance. This architecture solution is easy to learn, understand and implement.
However, coupling tends to be really strong with this design, especially between the Service/Repository/Entity layers.
Because this architecture does not enforce strong boundaries between business-logic and technical-logic, service layer classes usually tend to turn into 1000+ Lines of Code (LoC) Swiss Army knife classes.
Meeting Hexagonal-ish Architecture
When joining BlaBlaCar, I discovered that most projects were organised around 3 main modules:
- application
- domain
- infrastructure
I’d never worked on a project organised this way, and therefore didn’t know where to find the classes I was looking for. I was often asking myself organisational questions:
- “Where is the repository?”
- “Where should I put this HTTP client?”
- “Am I breaking any rule by putting this Utils class in this module?”
So I did what I always do when in doubt: asked Google.
An internet search using those terms led me towards 3 different architectural patterns that rely on such an organisation:
- Hexagonal Architecture (or Ports & Adapters Architecture, it’s the same)
- Onion Architecture
- Clean Architecture
Just by googling and reading the definition of those concepts, it was not easy to understand the clear difference between each. Let’s rather focus on the similarities:
- Focus on Core Business Logic: All three emphasise separating the core business logic of an application from external concerns.
- Layers and Dependency Rules: They use layers to organise code, but the key is how these layers interact. In all three, dependencies point inwards: the core business logic sits safely in the centre, unaffected by changes in external layers like data access or user interfaces.
- Port/Adapter Mechanism (Hexagonal) and Its Variants: Hexagonal architecture explicitly uses the concept of ports and adapters to connect the application with the outside world. Onion and Clean architectures follow a similar approach but with different terminologies. This means in practical terms, swapping out a database or a web service doesn’t require changes to the business logic.
In the end, those standards differ mostly on the terminologies and abstractions, but the intent behind all 3 is mostly the same.
To keep it simple and understandable, let’s focus on “Hexagonal Architecture” for the rest of this article.
Understanding Hexagonal Architecture
There are 4 key takeaways to properly understand Hexagonal Architecture:
- The Domain Input Port is injected into the Application Adapter class: simply put, the domain class is injected into the Controller
- The Domain only depends on interface, not implementations: the business logic must be as pure as possible, to achieve loose-coupling and not to be polluted by technical considerations. This means the Domain layer defines interfaces that the Application and Infrastructure layers implement, rather than importing code from those layers.
- Each layer has its own data object: if we need to manipulate an object in each of the layers, we need to make 3 distinct objects and their corresponding mappers. For instance, the Domain must not be polluted by a Data Transfer Object (DTO) from the Application. The Application must not return an Entity object from the Infrastructure. The Infrastructure must not persist a Domain object from the Domain.
- The Domain Output Port is implemented by the Infrastructure Adapter class: the Domain defines an interface describing the action to perform, and the Infrastructure implements this interface through a class having the concrete code performing the action.
As a member of the Payment team, I have firsthand experience with the challenges and intricacies of implementing Hexagonal Architecture in a payment service. This context allows us to delve into a practical example that highlights the key concepts and their relevance throughout the rest of the article. Let’s explore how this translates into Java classes for a Payment-related service:
The corresponding code would look like this:
In the application layer, the Controller imports the Service interface (Input Port) from the domain and uses it.
In the domain layer, the Service interface (Input Port) and Repository interface (Output Port) are declared.
A Service Adapter implements the Service interface and performs actions using the Repository interface.
Notice that the domain class has no import from other layers.
In the infrastructure layer, the Repository Adapter imports the Repository interface from the domain and performs some action.
Theory versus reality
That’s the theory. But in practice, some flaws start to appear:
- Mapping Complexity: The Application and Infrastructure layers need mapper classes to convert a
Payment
into aPaymentDTO
and to convert aPayment
into aPaymentEntity
.
This can quickly snowball into a mapping hell, especially if there are a lot of different objects in the Infrastructure layer (as it’s the case for a software making numerous HTTP calls), or if the software is mostly doing pass-through logic between the Application and Infrastructure layer. - Simplification: Instead of having a
PaymentServicePort
Input Port, we could inject the concretePaymentService
implementation from the Domain directly into thePaymentController
of the Application without breaking the core principles. While this deviates slightly from traditional Hexagonal Architecture by eliminating the interface between the Application and Domain, it keeps the Domain unpolluted and maintains the overall goal of loose coupling and separation of concerns.
Confronting the theory with the modern trends towards smaller, more focused codebases, it becomes clear that such a design can feel overly cumbersome for “light” applications.
In scenarios where the requirements are relatively simple, such as a basic CRUD application, Layered Architecture might suffice without the added complexity.
However, as the complexity increases, especially with more team members on the project and connections to multiple external systems, a more structured approach like Hexagonal Architecture becomes justifiable.
That said, applying a pure Hexagonal design to lightweight services often results in excessive boilerplate code, which can detract from the understandability and maintainability of the software.
Moreover, the refactoring cost to shift an existing project into an Hexagonal Architecture compliant project can be quite high.
While it can make sense to follow such a pure and academic design for a big service (a monolith for instance), it’s way more debatable for a small service, such as a microservice.
A monolith tends to have a long life-cycle within a company (5+ years). Being able to completely swap the database technology and the related infrastructure code can be useful (migrating from Oracle to MongoDB for instance is a somewhat realistic scenario).
But designing a 3000 LoC microservice around the idea that we might someday want to change the database technology is probably overkill.
If the project is small, the refactoring cost is low anyway. And the probability of the microservice to be sunset in favour of a new microservice is higher than the probability of changing database technology on a given microservice.
Pragmatic Approach
Taking into account the concerns highlighted in the previous section, let’s explore a pragmatic approach to this design solution. We will detail our Payment team refactoring journeys to bring codebases closer to Hexagonal Architecture principles, but with less overhead than required by the theory.
Layers
The usual way to separate a Java application through application/domain/infrastructure layers when following the Hexagonal Architecture principles is to create distinct Maven modules or Gradle sub-projects.
In our Payment team, projects were already divided into 3 main packages (application/domain/infrastructure).
As this approach was more lightweight to set up, we decided to stick to it, and have a package-based separation rather than a module-based separation.
However, the content of those packages was sometimes messy, with no respect to the principles behind the names of those packages. For instance:
- some business logic classes for payment processing were located in the application package instead of the domain package
- some API clients to call another microservice were located in the application package instead of the infrastructure package
- some persistence logic was located in the application package instead of the infrastructure package
We took the time to review our codebase and do some package refactoring in order to have all our classes in the right module, respecting the application/domain/infrastructure separation of concerns.
Output port
The academic Hexagonal Architecture principles expect us to have some output port, meaning having an interface declared in the domain layer that describes the infrastructure operation to perform, and have a concrete implementation in the infrastructure layer.
For instance:
- having a
PaymentRepositoryPort
in the domain - having a
PaymentRepositoryAdapter
in the infrastructure, implementingPaymentRepositoryPort
Following this, there is no import statement of an infrastructure class into the domain layer. The domain is kept pure. It’s the infrastructure class that is importing an interface of the domain. We achieve Inversion of Control.
Our codebase was already implementing such a logic: we always had a Storage interface defined and implemented by a concrete Storage implementation. We sometimes had to make a light package refactoring since the interface and the implementation were both located either in the same domain module or either the same infra module. But overall, this part was easy to implement.
We however haven’t adopted the Port/Adapter naming, to avoid a too big refactoring of the codebase. We ended up sticking to IPaymentStorage
interface and PaymentStorage
concrete class (this naming convention is debatable).
When starting from scratch on a new project, it might be clearer to follow the Port/Adapter convention, at least for output ports, to make it clearer within the code when we are crossing a layer boundary.
Input port
The academic Hexagonal Architecture principles expect us to have some input port, meaning having an interface declared in the domain layer that describes the business logic to perform, and have a concrete implementation in the domain layer as well
For instance:
- having a
PaymentServicePort
in the domain - having a
PaymentServiceAdapter
in the domain as well, implementingPaymentServicePort
Following this, the application layer imports an interface of the domain.
Our codebase was not respecting this principle. Moreover, since no Inversion of Control is achieved here, the importance of having an interface is less flagrant: in both cases, the application layer imports something from the domain layer, either the interface, or the concrete implementation. Based on the low evident benefits, we took the pragmatic decision of not having an input port: the application layer directly imports the concrete service of the domain.
Data object
Probably one of the most controversial takes of our approach is to not have a dedicated object for each layer.
The academic Hexagonal Architecture principles expect us to have:
- Data Transfer Object (DTO) for the application layer
- Domain Object for the domain layer
- Entity Object for persistence or DTO for API communication within the infra layer
It is theoretically expected that, when crossing a layer’s boundaries, a mapper will convert objects according to the layer being entered.
For instance, when the domain layer requests the infrastructure layer to persist a Payment
object, a mapper will be used by the infrastructure to convert it into a PaymentEntity
object.
In the Payment team, while we do some mapping between DTO and Domain Object, being from application to domain layer, or domain to infra layer, we don’t have an Entity Object dedicated to persistence.
This is somewhat related to the persistence technology we are using. The mapping is achieved by a Mapper class within the infrastructure layer. This Mapper describes the SQL operation to perform, and maps the value of the Domain Object directly to the database column.
This approach is somewhat debatable, as it induces a strong coupling between the Domain and the database in favour of less mapping boiler-plate code.
Pragmatic vs Academic
To sum up, here is how the so-called “Pragmatic” Hexagonal Architecture compares to the “Academic” one:
Pros of Pragmatic over Academic
- Streamlined implementation, and lower complexity, especially for small projects
- Flexibility in practice as adding a new feature or refactoring the code logic requires less changes
Cons of Pragmatic over Academic
- Reduced explicitness and clarity, as there is no Port nor Adapter naming. While browsing the code, it’s less explicit that we are crossing a layer boundary.
- No input ports. This can make testing more challenging.
- No Java modules per layer, but rather Java packages. Boundaries and encapsulation are stronger with modules.
- The business logic object is used in the infrastructure layer for persistence, hence we don’t have mappers between domain layer and the infrastructure layer when it comes to persistence. We therefore have tighter coupling between domain and infrastructure layer with this approach.
From these lists, we can see that we sacrificed several principles for the sake of simplicity.
However, it is possible to pick the tradeoff we want to do and be less extreme.
Return on Experience
Many teams at BlaBlaCar have implemented Hexagonal Architecture to some extent.
Depending on how “academic” the implementation is, adding a new feature to the codebase can be a bit cumbersome, but the reduced coupling with external services and third-party providers is noticeable and appreciated.
Enforcing the respect of the boundaries between the layers can be challenging, but technologies such as ArchUnit can help tackle this issue, by detecting non-compliant import statements for example.
For a microservice acting only as a pass-through, Hexagonal Architecture is however not optimal, as a lot of the developer energy goes into the mapping, which is of lesser value when it comes to domain “Entity” objects of such a service.
Conclusion
Our team believes that the so-called “Pragmatic” Hexagonal Architecture is a viable and valuable approach for microservice projects.
It’s a tradeoff between the purity of the “Academic” Hexagonal Architecture and the simplicity of the Layered Architecture. It’s simple enough, but also clean enough.
This solution is an attempt to find the right balance between over-engineering and strong-coupling.
But as usual, there is no silver-bullet: the decision to follow an architecture pattern must be taken based on the usage and the scale of your project, and very importantly, the standard must be accepted and understood by your teammates.
I would like to thank Christophe Maillard, Sigbjørn Dybdahl, Denis Wernert and Victor Rubin for their assistance in writing this article.