How to develop software more flexibly with hexagonal architecture

30 August 2022

Written by: Łukasz Wełnicki

Software products are developed rapidly these days. The pace prompts creators to make assumptions and decisions early in the development process – for example about data sources, communication mechanisms, ways of data presentation or application deployment. 

This is fine until something along the way forces them to introduce changes to the application. Triggers for these changes may be related to questions of third-party product licensing, application scalability, organization growth, or ease of development.

The harsh fact is this: the more a system grows, the more difficult it becomes to replace its external components or to restructure it. Because of that, software developers should try to delay key design decisions as much as possible. One of the things that can help them with this task is a suitable local application architecture.

Hexagonal architecture is a pattern that will give you more freedom in application development. It is based on layered architecture, so we’ll get a quick overview of that first.

In this article, we will see:

What is layered architecture and its subtypes?

The most traditional and widely used architecture is called Layered Architecture. A vast amount of applications use it. The basic idea is simple: divide your software into layers, each containing components of similar behavior and roles. Communication between the layers goes downwards.

Layered Architecture comes in two types:

See Fig. 1 for a comparison of strict and relaxed layered architecture:

Fig. 1: Strict vs. relaxed layered architecture

In Fig. 2, we can see an example of Layered Architecture with the following four layers: User Interface, Application, Domain, and Infrastructure. Flow of control (indicated by the arrows) points in the same direction as source code dependencies. 

Fig. 2: Layered architecture example

A different flavor of Layered Architecture is shown in Fig. 3. In it, we split the source code dependencies into vertical slices, each representing a particular functionality. This type of architecture is most commonly found in backend applications.

Fig. 3: Layered architecture with vertical slices

In this approach, vertical slices should communicate with each other only through well-defined interfaces. This is necessary to avoid tight coupling between them so that a change in one slice won’t affect another. Unfortunately, this is hardly ever the case in production systems, which often results in the big ball of mud antipattern.

What is dependency inversion and why do we apply it?

One of the SOLID principles – the last one – states that:

 A: High-level modules should not import anything from low-level modules. Both should depend on abstractions (e.g., interfaces).
B: Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.

Notice that in the examples above, Domain, a high-level module, depends on Infrastructure, a low-level module. So the rule is broken. We can fix that by inverting the dependencies between them, as shown in Fig. 4.

 

Fig. 4.: Applying dependency inversion

In this arrangement, the flow of control points in the opposite direction to the source code dependencies for Domain and Infrastructure layers.

This idea inspired the development of a different architectural style – Ports and Adapters, also known as Hexagonal Architecture.

What is hexagonal architecture and its elements?

Introduced in 2005 by Alistar Cockburn, Hexagonal Architecture (also known as Ports and Adapters) is based on layered architecture, but the dependencies between Infrastructure and Domain layers are inverted.

Additionally, we create a distinction between the application’s core and the “external world”. The application’s core implements business-critical logic and is independent of any external components (i.e. “external world).

These external components include any input and output of the application, for example, client services, client terminal, SQL database, S3 buckets, SNS, SQS, Kafka, and Rabbit, to name a few. 

As we mentioned, the application’s core stands entirely on its own and executes only business logic, so you can build it without any connection to external services. The time for that will come later. Once the core is ready, you can link it to the external world in various ways through an I/O system based on ports and adapters. See Fig. 5 for an overview of the architecture.

Fig. 5: An overview of the hexagonal architecture. The hexagon represents the Application’s Core, which can communicate with the external world using the ports and adapters.

Ports and adapters architecture is a perfect fit for a system developed using Domain Driven Design. This is because DDD can be applied to Application Core without any restrictions imposed by external components.

Let’s take a look at the Application Core and its elements, i.e. layers.

Domain Model

This part (Fig. 6) is created using tactical DDD. Aggregates, Entities, Value Objects, Domain Events, Repositories, and Factories live here. 

Fig. 6: Domain Model

Domain Services

This layer (Fig. 7) encloses the Domain Model. It consists of higher-level abstractions from DDD, like Domain Services and Use Cases. Taken together, Domain Services and Domain Model represent the Domain Layer known from the classic layered architecture.


Fig. 7: Domain Services

Application Layer

This layer (Fig. 8) encloses the Domain Layer. It consists of Application Services.


Fig. 8: Application Layer

What are ports in hexagonal architecture and how to use them?

In ports and adapters architecture, the Domain layer communicates with the external world through two types of ports: primary and secondary. 

Primary ports

Primary ports (also known as incoming or driving) are used to communicate with the Domain. They are defined as interfaces within the Domain layer, with concrete implementations also living within the domain. In my example illustrated in Fig. 9, I have defined primary ports as specific Use Cases.

public interface CreateProduct {
   Either<ProductError, Product> createProduct(CreateProductCommand createProductCommand);
}
public interface DeleteProduct {
   void deleteProduct(ProductId productId);
}
public interface GetProduct {
   Either<ProductError, Product> getProduct(ProductId productId);
}
public interface UpdateProduct {
   Either<ProductError, Product> updateProduct(UpdateProductCommand updateProductCommand);
}

Fig. 9.: Primary ports defined as specific Use Cases

They are the contract that the domain exposes and constitute the only way to communicate with the domain.

Secondary ports

Secondary ports (also known as outgoing or driven) are used by the domain to communicate with the external world. They are defined as interfaces within the domain, with concrete implementations living within the Infrastructure layer.

In my example (see Fig. 10), the secondary port is a classical Repository, a pattern familiar from DDD. Notice, however, that this interface has nothing to do with a database, hibernate, JPA, or any other implementation detail. It is just a definition of what the Domain needs to communicate properly to the external world.

 

public interface ProductRepository {
   Option<Product> findById(ProductId productId);
   Product save(Product product);
   void delete(ProductId productId);
}

Fig. 10: Secondary posts

What are adapters in hexagonal architecture and how to use them?

Ports, both primary and secondary, would be of no use without adapters. Ports are only the definition of what needs to be done, while adapters define how it is done. Like ports, adapters are also divided into primary and secondary ones.

Primary adapters

Primary adapters are the concrete implementations of primary ports. They also live within the domain. In my example (Fig. 11), there is just one primary adapter that implements all the use cases.

 

public class ProductService implements CreateProduct, UpdateProduct, DeleteProduct, GetProduct {
   private final ProductRepository repository;
   public ProductService(ProductRepository repository) {
       this.repository = repository;
   }
   @Override
   public Either<ProductError, Product> getProduct(ProductId productId) {
       return repository.findById(productId)
               .toEither(() -> ProductNotFound.of(productId));
   }

   @Override
   public Either<ProductError, Product> createProduct(CreateProductCommand createProductCommand) {
       return Product.create(createProductCommand)
               .map(repository::save);
   }

   @Override
   public Either<ProductError, Product> updateProduct(UpdateProductCommand updateProductCommand) {
       return getProduct(updateProductCommand.id())
               .flatMap(product -> product.update(updateProductCommand))
               .map(repository::save);
   }

   @Override
   public void deleteProduct(ProductId productId) {
       repository.delete(productId);
   }
}

Fig. 11: One primary adapter implements all the use cases

This particular primary adapter has a secondary port injected – called ProductRepository. It is of no interest to the primary adapter which secondary port implementation it will use. The allocation is made by a Dependency Injection tool or through a manual application setup (for example in testing). 

Primary adapters can then be used by the Application Layer (Fig. 12). I have described this layer as a place where Application Services live. Typical responsibilities of an Application Service are:

@Service
@Transactional

public class ProductApplicationService {
   private final GetProduct getProduct;
   private final CreateProduct createProduct;
   private final UpdateProduct updateProduct;
   private final DeleteProduct deleteProduct;

   public ProductApplicationService(
           GetProduct getProduct, CreateProduct createProduct, UpdateProduct updateProduct,
           DeleteProduct deleteProduct) {
       this.getProduct = getProduct;
       this.createProduct = createProduct;
       this.updateProduct = updateProduct;
       this.deleteProduct = deleteProduct;
   }

   public Either<ProductError, Product> getProduct(ProductId productId) {
       return getProduct.getProduct(productId);
   }

   public Either<ProductError, Product> createProduct(CreateProductCommand createProductCommand) {
       return createProduct.createProduct(createProductCommand);
   }

   public Either<ProductError, Product> updateProduct(UpdateProductCommand updateProductCommand) {
       return updateProduct.updateProduct(updateProductCommand);
   }

   public void deleteProduct(ProductId productId) {
       deleteProduct.deleteProduct(productId);
   }
}

Fig. 12: Application Service

Secondary adapters

Secondary adapters are implementations of secondary ports. They live within the Infrastructure layer. 

This is the first place where any important decisions are made. In Fig. 13, I want to use JPA and a relational database. My domain does not know about this implementation detail.

@Repository
class SqlProductRepository implements ProductRepository {
   private final JpaProductRepository productRepository;
   private final EntityMapper entityMapper;

   public SqlProductRepository(JpaProductRepository productRepository, EntityMapper entityMapper) {
       this.productRepository = productRepository;
       this.entityMapper = entityMapper;
   }

   @Override
   public Option<Product> findById(ProductId productId) {
       return Option.ofOptional(productRepository.findById(productId.value())
               .map(entityMapper::from));
   }

   @Override
   public Product save(Product product) {
       var storedEntity = productRepository.save(entityMapper.from(product));
       return entityMapper.from(storedEntity);
   }

   @Override
   public void delete(ProductId productId) {
       productRepository.deleteById(productId.value());
   }
}

Fig. 13: JPA-specific Secondary Adapter implementing a Secondary Port

I could also have another secondary adapter implement `ProductRepository`, for example `InMemoryProductRepository` used for testing. See Fig. 14 below:

 

public class InMemoryProductRepository implements ProductRepository {

   private final Map<ProductId, Product> map = new ConcurrentHashMap<>();

   @Override
   public Option<Product> findById(ProductId productId) {
       return Option.of(map.get(productId));
   }

   @Override
   public Product save(Product product) {
       map.put(product.id(), product);
       return product;
   }

   @Override
   public void delete(ProductId productId) {
       map.remove(productId);
   }
}

Fig. 14: In-memory Secondary Adapter implementing a Secondary Port

Thanks to such implementation, writing unit tests for the Domain is very simple. It doesn’t require you to set up framework-specific orchestration for such a small fragment of application and also does not need any mocking framework, which most of the time relies on reflection behind the covers. Fig. 15 shows my implementation of such a test.

class ProductServiceTest {

   private final InMemoryProductRepository repository = new InMemoryProductRepository();
   private final ProductService productService = new ProductService(repository);

   @Nested
   class GetProduct {

       @Test
       void wontGetProductWhichWasNotStored() {
           // when
           var result = productService.getProduct(ProductId.random());

           // then
           assertThat(result.isLeft()).isTrue();
           assertThat(result.getLeft()).isInstanceOf(ProductError.ProductNotFound.class);
       }

       @Test
       void findsStoredProduct() {
           // given
           var product = DomainGenerators.randomProduct();
           repository.save(product);

           // when
           var result = productService.getProduct(product.id());

           // then
           assertThat(result).singleElement().isEqualTo(product);
       }
   }

   @Nested
   class CreateProduct {

       @Test
       void savesInRepository() {
           // given
           var createProduct = DomainGenerators.randomCreateProduct();

           // when
           var result = productService.createProduct(createProduct);

           // then
           assertThat(result.isRight()).isTrue();
           assertThat(repository.findById(result.get().id())).singleElement().isEqualTo(result.get());
       }
   }

   @Nested
   class UpdateProduct {

       @Test
       void wontUpdateProductWhichWasNotStored() {
           // given
           var updateProduct = DomainGenerators.randomUpdateProduct(ProductId.random());

           // when
           var result = productService.updateProduct(updateProduct);

           // then
           assertThat(result.isLeft()).isTrue();
           assertThat(result.getLeft()).isInstanceOf(ProductError.ProductNotFound.class);
       }

       @Test
       void updatesProduct() {
           // given
           var product = DomainGenerators.randomProduct();
           repository.save(product);
           var updateProduct = DomainGenerators.randomUpdateProduct(product.id());

           // when
           var result = productService.updateProduct(updateProduct);

           // then
           assertThat(result).singleElement().isEqualTo(product.update(updateProduct).get());
       }
   }

   @Nested
   class DeleteProduct {

       @Test
       void noExceptionIsThrownOnDeletingNotExistingProduct() {
           // expect
           assertThatNoException().isThrownBy(() -> productService.deleteProduct(ProductId.random()));
       }

       @Test
       void deletesProduct() {
           // given
           var product = DomainGenerators.randomProduct();
           repository.save(product);

           // when
           productService.deleteProduct(product.id());

           // then

           assertThat(repository.findById(product.id())).isEmpty();
       }
   }
}

Fig. 15.: Unit tests for the domain

There is one very important rule regarding adapters: they must not talk to each other. This means you are not supposed to call one adapter from within another.

So far, I have covered the Application Core, but what is inside the Infrastructure Layer? In my example (Fig. 16), you will find all the database-related components and also the REST API implementation.

Fig. 16: The inside of the infrastructure layer with database-related components and the REST API implementation

How to test hexagonal architecture?

Ports and adapters architecture has some strict rules regarding source code dependencies. Complying with these rules should not only be a matter of convention but also needs to be imposed by tests. Archunit is a great tool to test your architecture. It has a special DSL that lets you easily verify your ports and adapters structure (Fig. 17).

public class PortsAndAdaptersTest {
   private final JavaClasses classes = new ClassFileImporter().withImportOption(new DoNotIncludeTests())
           .importPackages("com.lukaszwelnicki.products");

   @Test
   void shouldFollowPortsAndAdaptersRules() {
       onionArchitecture()
               .domainModels("com.lukaszwelnicki.products.domain.model..")
               .domainServices("com.lukaszwelnicki.products.domain.service..")
               .applicationServices("com.lukaszwelnicki.products.application..")
               .adapter("persistence", "com.lukaszwelnicki.products.infrastructure.persistence..")
               .adapter("rest", "com.lukaszwelnicki.products.infrastructure.rest..")
               .check(classes);
   }
}

Fig. 17: Testing ports and adapters architecture with Archunit

Why did Netflix opt for hexagonal architecture?

Netflix doesn’t only stream content, it also makes it. Every year, hundreds of original productions land on the platform. Creating even one is a multi-step process relying on input from numerous external sources.

In the case of Netflix, data such as scripts, actors, payrolls, locations, etc., lie scattered across various microservices. Each microservice may be using a different protocol to communicate, so coordinating production requires an app capable of integrating the data quickly, regardless of protocols. 

Netflix was looking for a template solution that would allow them to keep their app’s business logic intact and switch between data sources with relative ease. Hexagonal architecture seemed a perfect fit.

Shortly after implementing the architecture, Netflix’s app had to adapt to a different data source – specifically, to shift from JSON API to GraphQL. It managed to do that in two hours.

Perhaps your organization also deals with multiple data sources and needs a fast and reliable way to switch between them with minimal code revisions. If that is the case, hexagonal architecture is the way to go.

What are the pros of Hexagonal Architecture?

I’m not writing about hexagonal architecture from an abstract point of view. Several commercial products I was involved in leveraged it, allowing me to make a few observations on its use and applications.

Once you get used to it, hexagonal architecture becomes a natural choice for software development. Even a newbie can write it using Test-Driven Development because it’s easy to get full test coverage on the domain.

When it comes to potential applications, hexagonal architecture lends itself especially well to microservices – an approach advocated by Vaughn Vernon in his book “Implementing Domain Driven Design”.

My own experience also confirms that hexagonal architecture offers great flexibility in juggling various solutions without affecting the application’s core. For example, on one occasion my team had to switch from JPA to JOOQ. The change bore completely no impact on our domain. All the tests remained intact since we established our ports in a way the domain needed them.

What are the cons of Hexagonal Architecture?

There are a few caveats to using hexagonal architecture.

For one, it’s not a good choice for a CRUD. At this point, I should admit that the demo application from this article is a CRUD, and it suffers from over-complication by multiple interfaces and mappers. Therefore, keep in mind you should only use Ports and Adapters architecture if you know that your app will perform non-trivial business logic.

Another thing to remember is that things get complicated when you go reactive. As a rule, the domain in hexagonal architecture should be framework and library agnostic, as it pays off in the long term and lets you be more flexible. However, when we wanted to use Spring Webflux, which operates on Flux and Mono concepts, it had to be transferred into the domain.

Finally, package structuring in hexagonal architecture is a question of choice. Some teams prefer one way of doing it, others prefer another. Although the freedom might seem good on the surface, it may make conventions difficult to track between teams.

Should you use hexagonal architecture?

Many factors inform the choice of the right solution for software development. On the one hand, your organization may face similar challenges to those of Netflix, prompting you to consider hexagonal architecture; on the other, reactive design patterns in your software may tip the scales against it.

If you’re unsure of what to do, contact our experts. We’ve done more than our share of work with hexagonal architecture and can advise you on the best course of action.

All of the code samples can be found here:
https://github.com/lukaszwelnicki/ports-and-adapters

bannerbanner

Your software development experts

We’re a team of experienced and skilled software developers – and people you’ll enjoy working with.

Start Your Projectadd