hexagonal architecture with spring-boot
src: Hexagonal Architecture With Spring Boot - 2023-09-23
Abstract
Good practical implementation of hexagonal architecture with spring-boot framework.
Reviews
Why did I want to read/watch this? There are numerous ways to implement hexagonal architecture, which can be both a blessing and a curse. On the one hand, it allows us to tailor the project structure to our specific needs. On the other hand, having access to various examples can provide valuable inspiration and help me incorporate good ideas into my own projects.
What did I get out of it? I was interested in seeing how other engineers handled the transactional aspect of use cases, as my own approach - creating a dedicated use case with annotated
@Transactional
- didn’t feel ideal. However, the author’s solution is more elegant and has given me a better approach to adopt.
Hexagonal architecture has become a popular architectural pattern for separating business logic from the infrastructure. This separation allows us to delay decisions about technology or easily replace technologies. It also makes it possible to test the business logic in isolation from external systems.
In this article, we will look at how to implement hexagonal architecture in a Spring Boot application. We will separate the business logic and the infrastructure in their own modules and see how we can implement and test these modules in isolation.
This article is a practical hands-on tutorial and expects that the reader has a basic understanding of the principles behind hexagonal architecture. This background information is available in the Hexagonal Architecture Explained article.
Use Cases and Business Logic
A core attribute of the hexagonal architecture is that we can implement the business logic separately from the infrastructure. This means that we can start by focusing on the business logic alone.
We are going to start with an example application that allows a customer to order coffee. We have some rules related to making an order and preparing the order.
- The customer can order a coffee and choose the type of coffee, milk, size and whether it’s in store or take away.
- The customer can add more items to the order before paying.
- The customer can cancel the order before paying.
- When the order is paid, no changes are allowed.
- The customer can pay for the order with a credit card.
- When the order is paid, the customer can get a receipt.
- When the order is paid, the barista can start preparing the order.
- Once the barista is finished, she can mark the order ready.
- When the order is ready, the customer can take the order, and it’s marked taken.
One advantage of the hexagonal architecture is that it can encourage the preferred way of writing use cases. The use cases should live on the boundary of the application, unaware of any external technologies.
We can identify two primary actors: a customer making the order and a barista preparing the order. Knowing that the ports in hexagonal architecture are a natural fit for describing use cases of the application, this leads to introducing two primary ports: OrderingCoffee
and PreparingCoffee
. On the other side of the application, we need a couple of secondary ports for storing the orders and payments.
Coffee shop use cases
The OrderingCoffee
and PreparingCoffee
ports need to fulfill the requirements we have related to making an order and preparing coffee.
Similarly, our secondary ports could be called simply Orders
and Payments
and their job is to store and fetch orders and payments.
We will need some entities in our domain model. A line item in an order will hold the type of coffee, milk and size of a beverage. We will also need to track the status of the order.
There are also classes for payments, credit cards and receipts. In this example, we didn’t add any behavior to them, so they are just Java records.
Next, we need to implement the use cases inside our application. We are going to create a CoffeeShop
class that implements the OrderingCoffee
primary port. This class will also call the secondary ports Orders
and Payments
.
Ordering coffee use case
The implementation is pretty straightforward. Here is an example of paying an order that needs both secondary ports.
The role of the CoffeeShop
class is to orchestrate operations on entities and repositories. It’s implementing the primary ports as use cases and using the secondary ports as repositories.
In our implementation, we mostly implemented business logic in domain entities. Here is an example of marking an Order
paid.
Now for the second use case, we are going to implement a CoffeeMachine
service class that implements the PreparingCoffee
primary port.
Preparing coffee use case
We could have implemented this functionality in the same class, but we have made a design decision here to split the implementation of different cases in their own classes.
Since we want to delay decisions about technologies, we can create stub implementations for the secondary ports we have. The easiest way to do this for repositories is to store the entities in a map.
This allows for implementing the business logic without having to worry about persistence details at this point. In fact, when working iteratively, we could ship the first version of the application with these in-memory stubs.
One thing to note here is that we don’t need interfaces for the primary ports! Having these interfaces available makes it easier to visualize the role of primary ports, but they are unnecessary. We only need interfaces for the secondary ports in order to invert the dependencies there.
Interfaces with a single implementation are useless interfaces. Since our application core is not something that we would replace with another implementation, we could just expose the public methods of the concrete implementation as the primary ports. However, having these interfaces in place makes it more apparent what exactly is the boundary of the application.
Acceptance Tests
We can also use these in-memory stubs for testing and enable blazing fast tests. We can start by writing some acceptance tests for our use cases. When working in a BDD manner, this would be where we would start even before the implementation.
Acceptance testing the application in hexagonal architecture
These tests treat the application as a black box and execute use cases only through the primary ports. This approach makes these tests more resistant to refactoring since we can refactor the implementation without having to touch the tests.
Here are some examples.
People often implement acceptance tests exercising the entire application through REST endpoints and going all the way to the database. Such tests are complex to write and slow. This doesn’t have to be the case!
Acceptance tests ensure we are building the right thing. They should capture the business requirements and don’t have to deal with technical concerns. The goals of the end-user have nothing to do with the choice of technologies. It’s completely possible to implement them with tests that are like sociable unit tests.
Writing our acceptance tests this way makes them fast, more resistant to refactoring, and focuses on the use cases of the application.
Unit Tests
We might also decide that there is some logic that warrants testing that logic in isolation. For example, calculating the cost of the order might be such a thing. Instead of testing everything through the use cases, we might end up writing more focused tests.
Here is an example of how we might calculate the cost of an order.
Each line item knows how to calculate its own cost. For simplicity, we assume that every small drink costs 4.0 and large drink 5.0.
To test this logic, we can choose to test the Order
class directly with unit tests.
Unit testing business logic
Here is an example unit test that creates some orders and then verifies that we calculate the cost of the order correctly.
Note that we have decided to call the test class OrderCostTest
instead of just OrderTest
. This emphasizes the idea that we should test behavior and not classes or methods.
Whether to use more focused unit tests is a case-by-case choice. We can avoid a combinatorial explosion in tests on a higher abstraction level by writing some more focused tests instead.
Primary Adapters
The next logical step would be to add some primary adapters. Although the application itself is unaware of who is calling its use cases, we have to provide means for the outside world to communicate with the application.
Let’s look at an example that implements a REST endpoint taking in an order. The controller implementation is as thin as possible, maps the incoming request to something understood by the application and calls a primary port of the application.
Here, we have put the mapping code between the domain and the response in the response object itself.
Note that we have chosen to use separate OrderRequest
and OrderResponse
objects. This is because the write and read models don’t have to be the same and could have unique properties.
Mapping of models in order controller
Strictly speaking, this kind of mapping strategy does not prevent domain logic leaking into the primary adapters. If we wanted complete isolation, we would define something like a PlacingOrdersCommand
model to go with the PlacingOrders
interface and deny direct access to the Order
from adapters. This would come with the cost of even more mapping code and is not justifiable for all applications.
Configuring the Application
Having the primary adapters ready is not enough. If we would now try to start the application, it would fail because of missing domain beans. Thus, we have to let Spring now how to wire our domain classes CoffeeShop
and CoffeeMachine
that do all the work.
The typical way to do this in a Spring Boot application would be to annotate the CoffeeShop
and CoffeeMachine
classes with the @Service
annotation. However, if we want to keep the framework details out from the application, we cannot do this.
So, how should we do this? One could create some configuration classes and add bean configurations for the required service classes manually. This would, however, be quite tedious in a large code base.
Instead, we can create a configuration that scans classes annotated with a custom annotation. First, we are going to create our own annotation inside the application.
This is just a marker interface that also clarifies that a class is implementing a use case.
Next, let’s add a configuration that scans for classes annotated with the annotation and adds beans for them.
Now we can annotate our domain classes without framework specific annotations. Spring Boot will find any classes annotated with the custom annotation and automatically create beans for them.
Integration Tests for Primary Adapters
To test the controller, we could mock the primary ports and then verify that the controller interacts correctly with the application. While this is a common approach and definitely would work, it has two major drawbacks.
First, it forces us to change the tests every time we refactor the interfaces between the adapters and the application. Second, testing the model mapping code is more error prone because we have to validate the ports are called with correct arguments.
We are going to use another approach for testing the primary adapters, where we inject the application as-is and reuse the in-memory stubs previously created. This approach is more resistant to refactoring and exercises the mapping code as part of the flow.
Testing the order controller with integration tests
In our tests, we need a test configuration that has bean configurations for the in-memory stubs.
This first imports the domain configuration and then adds the required bean configurations for the stubs.
If this would be the first shipped iteration of our application, we’d need to have bean configurations for the in-memory stubs inside the application, not only for the tests.
Now our tests have to import this test configuration. We can use the secondary ports to interact with in-memory stubs.
There is no need to use a mocking framework or stub method calls. There is no need to verify the arguments of those method calls either. We simply insert some objects into our in-memory stub to set up the desired state. This also exercises the mapping code without having to test that separately.
With this approach, we have to be careful to not start testing the application business logic in the adapter tests. Our tests should focus only on testing the controller’s responsibilities.
Secondary Adapters
When we have an entry point to the system ready, it’s time to implement secondary adapters. We are going to need persistence of the orders.
Mapping of models in orders JPA adapter
In the example, we are using JPA because it’s what most people are familiar with. Here we have the dependency inversion principle in action, where the application does not depend on the adapter directly but depends on a secondary port, which is then implemented by the adapter.
The OrdersJpaAdapter
takes care of the translation between the domain and the JPA entities. This means that we can create an OrderJpaRepository
interface which is called directly from the adapter.
The OrderEntity
itself holds the jakarta.persistence
annotations for ORM. A lot of applications pollute the domain model with such annotations. Here we have a clean separation of those concerns with the cost of having to do mapping between the models.
Here we have also put the mapping code between the domain and the JPA entity in the OrderEntity
class itself. We could also extract this into its own class, but we are trying to keep things simple.
Handling Transactions
Now that we have added persistence to our system, there is one good question: where should we put the transactions?
A use case is a natural unit of work. It would make sense to wrap a use case into a transaction. The traditional way to do this would be to annotate a method implementing the use case with the @Transactional
annotation. While this would be very practical, if we truly want to keep frameworks out of the application core, we can do better.
It turns out we can apply aspect-oriented programming for this. This is not a guide to aspect-oriented programming, but the idea is to add behavior to the existing code without having to alter the code itself.
First, we need something that executes a piece of code inside a transaction.
Next, we will create an aspect that finds a join point for a method and executes that method with the executor we previously created. This will effectively execute that method inside a transaction.
Basically, the code finds any classes annotated with @UseCase
and applies the TransactionalUseCaseAspect
to the methods in that class. This brings another useful quality to the @UseCase
annotation we created.
Finally, we need a configuration to enable the aspect.
Now our use cases are transactional automatically when we annotate a class with the @UseCase
annotation. This is one example of how we can add behavior that is not central to the business logic without cluttering the code with that functionality.
Integration Tests for Secondary Adapters
One way of testing the secondary adapters would be to reuse the acceptance test cases and configure the tests to use the secondary adapter that we need to test instead of using an in-memory stub. We could do this with some clever use of JUnit 5 test interfaces and default methods, for example.
While this is a valid approach, in this example, we have opted to test the secondary adapters separately. Testing the secondary adapters in isolation does not suffer as much from the problems we have testing primary adapters in isolation.
First, from the application perspective, it shouldn’t matter what adapter is behind a secondary port and we have already tested it using an in-memory stub. Second, we test the mapping code in the secondary adapter against a database container and we don’t need to verify any interactions.
Testing secondary adapters with integration tests
The tests are accessing the adapters only through the secondary ports. This will minimize the coupling of the tests to the implementation. We are also not testing the JPA repositories directly, but through the adapter. This will exercise the mapping code that maps the domain entities to the persistence entities.
Since the @DataJpaTest
annotation only configures beans for some JPA-related components like our repositories, we also have to scan for our adapter classes with @ComponentScan
.
In this example, we are using the H2 database, but in a production-ready application, we’d use something else, like PostgreSQL. In such case, it would be better to test the persistence adapter against the real database, e.g. using Testcontainers.
End-To-End Tests
Although we should rely that most of the functionality is already tested on lower levels, there can be some gaps in the testing that those tests miss. To gain confidence that everything works correctly, we also have to implement a few end-to-end tests or broad integration tests.
End-to-end testing of the coffee shop system
Here we have opted for implementing broad integration tests with a mocked environment, but you could also write end-to-end tests with a running server.
We have created helper methods for each of the endpoint calls, matching the steps in our use case. The tests touch each of the endpoints at least once but don’t make excessive assertions. Each of the steps only makes sure that the request succeeded.
These tests cover any gaps in our testing at the lower levels. Writing these tests like this makes it easier to write them and focuses only on making sure that we wired everything together correctly.
Structuring the Application
In our example application, there are just two Gradle modules in the settings.gradle
file.
The difference between these modules is that the coffeeshop-application
holds all the business logic and use cases of the application and does not depend on Spring Boot at all. In fact, the only dependencies it has are JUnit 5 and AssertJ.
This is where we can implement acceptance tests as sociable unit tests for the business logic. We can also add some more solitary unit tests for testing parts of the logic in isolation.
The second module, called coffeeshop-infrastructure
, implements all the adapters and adds all the configurations needed for the Spring Boot application. A notable difference is that this module has all the Spring Boot dependencies and also adds coffeeshop-application
as a dependency.
This is where we test those adapters with integration tests and add a few end-to-end tests to make sure we wired the entire solution together correctly.
Having the modules and dependencies set up like this allows us to enforce the architecture rules at the Gradle module level.
Internally, these modules are structured with a package structure that differentiates between primary and secondary ports and adapters.
Note that hexagonal architecture has no opinion about the internal structure of the application. The approach shown in the example is just one way of doing it.
Is It Worth It?
If we are going to adopt hexagonal architecture, we should be conscious of the benefits and costs of applying it. I’ve seen some articles saying that you should be more practical about your implementation of hexagonal architecture. If that is the case, why are we trying to use hexagonal architecture then?
Going down the rabbit hole of wanting to use a pattern is not a good idea. We should have a good reason to use something.
Here are some things we might consider that would speak against hexagonal architecture:
- Annotating entities with the persistence annotations inside the application to avoid mapping between models.
- Using Spring Data JPA in general. The power (and weakness) of JPA is that we don’t need separate models.
- Using Spring-provided implementations of features like application events inside the business logic.
If we really want a strict separation of business logic from the infrastructure, we don’t have to use JPA for persistence. Actually, we should question this choice and use something else because it’s not necessarily the best pick.
The hexagonal architecture allows us to defer decisions about technology. However, the choice of architecture from the get-go can also be a premature decision. If we understand the basic principles, it is possible to start with something lighter and allow the architecture to grow as the need rises.
Summary
In this article, we made a clean separation of business logic and infrastructure in their own Gradle modules in a Spring Boot application. We implemented some patterns that allowed us to keep the framework code outside of the application.
We also saw how to test the application in isolation without framework dependencies. We complimented these tests with integration tests on the infrastructure module level.
You can find the example code for this article on GitHub.