Hexagonal/Onion/Clean Architecture

1. Intro

I have used and seen multiple architectural patterns, but this one is one of my favorites. You can find it under multiple names: Hexagonal Architecture, Onion Architecture, and Clean Architecture. All those terms describe the same thing, they differ a little in the implementation details, but conceptually they are the same. They are trying to achieve a more maintainable software architecture.

Below, I have linked the original articles:

In this article, I want to share my views and perspective on this architectural pattern and elaborate a little bit on the software context from where this pattern emerged and what challenge is trying to solve.

If you don’t want to read the context, you can jump directly to Chapter 6 to read about Hexagonal, Onion, or Clean Architecture. However, I strongly recommend reading the context, since you will understand better the pattern.

2. Understanding the high level software context

Picture 1. High-level software context

Let’s start by understanding the typical high level software context, we have:

  • Interacting Actor: can be a user, an application, a device, another system, etc… that interacts with our system.
  • Our System: This is the software that we are building, and to which we will apply hexagonal architecture. Can be one microservice, can also be a monolith etc…
  • Dependencies: External applications/systems that our system depends on to perform its functions. We can talk here about databases, queues, caches and other infrastructure components. This also includes third-party providers that our system uses, or other applications/systems that we do not maintain. We either use it as a service, or somebody else maintains it.

Note: Some interactions are optional, in real life we can have systems that have no external dependency, or our system does not return a response. For the purposes of this article, we will assume that interactions are going in and out of our system.

3. Understanding our system

Inside a software system, high level view.

Picture 2. Inside a software system, high level view

If we zoom in into our system, we typically will see the following:

  • Some communication logic for incoming interactions
    • This is protocol-specific and can be: HTTP, GRPC, TCP, Custom protocol, etc..
  • Some business logic
  • Some communicaiton logic for outgoing interactions
    • This is also protocol-specific: HTTP, GRPC, TCP, Custom protocol, etc..

So far this is a simple system that we have, but in real life, we have multiple actors that interact with our system, and we have multiple external dependencies as well. A more realistic example would be the following:

Inside software system, real-world example

Picture 3. Inside a software system, real-world example

As you can see, for each interacting actor, we might have a different communication protocol; therefore, we need different communication logic for each protocol. In most of the tutorials online, mainly for the sake of simplicity, they only expose REST APIs, but in the real world is not uncommon to have multiple incomming communication protocols.

We have the same for outgoing, each of the dependencies has its own communication protocol. Some might use the same protocol, in the above picture both: Amazon S3 Bucket and Third Party Provider uses REST over HTTPs but this does not mean that we can fully reuse the same communication logic, most of the time we cannot, since each request is build in a different way, with different parameters etc… So at the end we still will have different code for each external dependency.

In the center, we have the business logic. The business most of the time is the same, but is not uncommon to have different business logic based on the interacting actor.

Fun Fact: I noticed while writing this article that most of the code an application has is around communication, the actual business logic code in most cases is very small.

3.1 Code Provenience

In the above picture 3, we realize that in a software system, we have a lot of logic to implement. But where does all this code come from?

Communication Code

The code that implements communication logic comes from frameworks and libraries already written. Each external dependency provides a client that can be used for communication with that dependency (ex: for MariaDB we have MySQL Driver). For executing HTTP requests we also have libraries. For incomming communications is the same, there are libraries that take care of that.

So there is no need to write communication code?

Not quite! Even though the communication code is available, we cannot use it out of the box; it requires configuration. We also need to define data structures for input/output when using this communication code. So in the end, we have a mix of using a provided library + some custom configuration/code. In rare cases, if it’s an in-house proprietary protocol, we might need to write the communication code ourselves as well.

Business Code

The code that implements business logic almost in all cases is self-written. Is specific to the business problem we are trying to solve. There might be rare cases where we can use a library if our business problem is very common, but also in that case, we still need to configure this library to our environment and needs.

4. The challenge with software development

Something is clear to me after all those years in the software industry: Change is constant. When we talk about change, we talk about two types:

  • Business Logic changes
  • Technology changes

Our world evolves, and the business needs to adapt to this new world. This means that the business logic needs to change as well. Fortunately, those changes are not that often, once a business logic is tested and works, usually will pass some time until that logic needs to be changed. I have seen examples of code written 50 years ago, and still working in production (in the banking domain).

On the other hand, technology evolves daily, and as developers, we often struggle to keep up with it. New frameworks, new databases, new protocols, and new products as a service appear every day. Not only this, but our existing frameworks get updated every day, new security patches are introduced, and new features as well. On top of this, business pays for some of the services they use, and they might get better deals from other providers, so providers change. I have seen big companies investing time and money doing big migrations (years of work) ex: migrating from Amazon AWS to Microsoft Azure.

If not managed well, all this change, especially the technology change can create maintainability problems. Will take a very long time to finish the migration to a new technology, and bugs can be introduced as well. All this means that in the end, the business will lose money. A business can also go out of the market if it cannot react quickly enough to market changes and competitors. Is not only about the money or the bankrupcy, is also on the impact on the system users. A bug in medical software can result in a patient being harmed or even killed.

5. Managing better the change

There are multiple things that we can do to manage well the change in technology and business logic, but one of the essential things is increasing the maintainability of the software. Having software that is easy to maintain will spare you a lot of trouble and will help you win in the long run as a business.

How does a maintainable software look like?

A maintainable software is organized in a way that is easy to understand and you can introduce changes (modification/extension) easily while minimizing the risk of introducing bugs.

6. Hexagonal, Onion, or Clean Architecture patterns

Hexagonal, Onion, or Clean architecture patterns are very similar, with different interpretations of the same idea. The goal with those architecture patterns is to help with software maintainability(by promoting separation of concerns, modularity, and testability).

These patterns increase maintainability by:

  • Separating the business code from the communication code
  • Adding abstraction layers, as a bridge between business code and concrete implementation of communication logic.
  • Treating each communication logic as a plugin that can be easily replaced without affecting the business logic or other communication layers.
  • Inverting the dependencies between the business layer and communication code. The business layer is not aware of the communication logic and, therefore does not need to be changed, if a communication logic changes.

I have added below a summary of the similarities and differences between them, so we can have a clear picture:

Similarities
  • Separation of Concerns: All three architectures emphasize dividing the codebase into layers or modules, each with a specific responsibility.
  • Testability: By decoupling the business logic from external dependencies, these architectures facilitate easier unit testing.
  • Modularity: Encapsulating the core business logic within inner layers or the hexagon promotes modularity, allowing for independent development and deployment of components.
  • Flexibility: Isolating the core logic from external frameworks or technologies enables the application to be more adaptable to changes.
Differences
  • Dependency Inversion:
    • Hexagonal: Uses ports and adapters, with the core defining the interfaces and the adapters implementing them.
    • Clean: Promotes dependency inversion through interfaces and abstractions, with high-level modules depending on low-level modules through abstractions.
    • Onion: Also uses interfaces to invert dependencies, with each layer depending only on the layers inside it.
  • Focus:
    • Hexagonal: Emphasizes the separation between the core business logic and external dependencies.
    • Clean: Prioritizes the separation of concerns and dependency inversion.
    • Onion: Focuses on the separation of concerns and the use of interfaces to decouple the application from external dependencies.
  • Visualization:
    • Hexagonal: Uses a hexagon shape to represent the core, with ports and adapters surrounding it.
    • Clean: Depicts layers as concentric circles, with the core at the center.
    • Onion: Also uses concentric circles to represent the layers, but with a focus on the domain model at the center.
  • Terminology:
    • Hexagonal: Uses terms like “hexagon,” “ports,” and “adapters.”
    • Clean: Uses terms like “core,” “entities”, “use case”, “interface,” and “adapter.”
    • Onion: Uses terms like “domain,” “interface,” and “infrastructure.”

Even though they seem to have a lot of differences, don’t let this fool you. We can argue all day about small implementation details and naming, but at the end of the day, they all do the same thing.

To understand them better, let’s continue with our example and terminology. So on a conceptual level we have this:

Conceptual diagram of Hexagonal/Onion/Clean Architecture

Picture 4. Conceptual diagram of Hexagonal, Onion, or Clean Architecture

Now I will try to map, my concepts described on this blog, with some wording used on those patterns:

  • Business Logic is called Domain.
  • Abstraction Layers are called: Ports
    • An Incoming abstraction layer is called: Incoming Port
    • An Outgoing abstraction layer is called: Outgoing Port
  • The concrete implementations of the communication logic are called: Adapters
    • An Incoming communication logic is called: Incoming Adapter
    • An Outgoing communication logic is called: Outgoing Adapter

I will use the above terminology from now on to describe this architectural pattern. So if we take our example from picture 3 and rewrite it with ports and adapters will look like this:

A real-world conceptual example of Hexagonal/Onion/Clean Architecture

Picture 5. A real-world conceptual example of Hexagonal, Onion, or Clean Architecture

You might have noticed that I have mixed concepts from all three patterns into one single diagram. This is on purpose for the following reasons:

  • To prove my point that they are the same, and they can be used interchangeably, and they don’t contradict each other.
  • For me, this structure is the one that is most complete, easy to understand, and makes the most sense.

Now with some imagination, we can reorganize the picture to look like an onion or hexagon (in my case a dodecagon), and hopefully looks clean.

Hexagonal/Onion/Clean Architecture
Hexagonal/Onion/Clean Architecture

Picture 6. Hexagonal, Onion or Clean Architecture

The arrows represent the dependency and which layer/component depends on the other layer/component.

  • Domain Objects rarely change, so conceptually they are at the center of the architecture. They are also unaware of other layers and components so they have fewer reasons to change.
  • Business Services from the domain layer, uses domain objects, to execute business logic. They should change only if the business logic changes. This layer shouldn’t know anything about adapter implementation details. This layer depends on the Ports as an abstraction layer to interact with the Adapter layer.
    • Business Services implement incoming ports
    • Business Services use outgoing ports to perform operations.
  • Ports do not depend on anything, they are just abstractions to help implement decoupling of the communication logic from business logic.
  • Adapters depend on the Port, they use or implement the ports
    • Incoming adapters use the ports to trigger business logic
    • Outgoing adapters implement the ports so they can be triggered by business services

To Notice: Both the Adapter and Domain layer depend on the Port Layer (an abstraction layer). Having this abstraction layer between them makes this architecture maintainable.

How do we transfer data between layers?

We need to transfer data between the adapter and the domain layer. This of course is done through the ports. Ports should come with its own DTOs (data transfer objects). We should not expose internal details (method, functions, classes, objects, variables, constants, etc..) from one layer to another, otherwise we remove all the advantages of this architecture.

7. Benefits of Hexagonal, Onion, or Clean Architecture pattern

  • Changes can be done in isolation without impacting other layers. If we receive a request to modify the business logic, we can make the change in the Domain layer, and the other layers are unaffected. The risk of introducing a bug is minimal since we have not touched the other layers.
  • Can be easily extendable, due to the nature of adapters. If we need a new communication channel, we can just add a new adapter.
  • Can be easily tested, because of decoupling with the Ports abstraction layer. Ex: if we need to test the business logic, we can easily mock the outgoing ports. If we need to test the adapter we can also do that, no need to go through the business logic. Things can be tested in isolation.
  • Easy technology migration. If we need to change the database and migrate to the coolest and latest one in the market, this can be easily done, we just need to write a new adapter and connect it to the interface. There is no need to change other parts of the software. This applies to all incoming and outgoing adapters.

Overall this leads to more maintainable software, more testable, and fewer bugs which will help the business to deliver software faster and with better quality.

8. When to use a Hexagonal, Onion, or Clean Architecture pattern?

Even though the concept is simple, implementing this pattern correctly can take some effort. For this reason, it is best to use this pattern for projects with medium and high complexity.

Let’s say when you have at least three adapters (incoming + outgoing). For simple projects with two adapters using the traditional layered architecture makes more sense and you have less overhead.

You can also use this pattern when you know that the codebase will grow and you will add more and more features. Generally is a good idea to have the structure put in place from the beginning, and then you can easily add ports and adapters.

9. Closing words

Hopefully, you now better understand the hexagonal, onion, or clean architecture pattern—why it’s here, how it works, what problem it solves, and what it can do for your business.

Now it only remains to implement it. One thing is to understand it conceptually, but one cannot fully understand something until it puts it to practice and implements it.

This was the case for me as well, I read multiple articles, but I fully understood it when I started to implement it in the project that I was working.

To help with the implementation journey, I plan to write another blog post with implementation details in various languages: Java, Go, and JavaScript.

Thanks for taking the time to read this article, and please do share it with others if you found this useful.

Leave a Reply

Your email address will not be published. Required fields are marked *

Close Search Window