Hexagonal Architecture Example: digging a Spring Boot implementation
The Hexagonal Architecture is a very powerful pattern. It helps you create more sustainable and better testable software by decoupling the business logic from the technical code. The current post delves into a Hexagonal Architecture example built with Kotlin & Spring Boot named TalkAdvisor. The source code is available in this GitLab repository.
To refresh our mind, here is a schema of a high-level implementation of the Hexagonal Architecture.
As a reminder, the left adapters use the domain API. And this is usually to expose the features to the consumer. On the other end, the right adapters integrate the domain with third-party services through its SPI.
TalkAdvisor is a demo application developed with Kotlin and Spring Boot using the Hexagonal Architecture. It recommends technical talks from YouTube based on user’s topic preferences. Therefore, it offers two main features:
- the user can create a profile where he stores his preferences of topics and talk duration.
- the recommendation of talks based on criteria or a user profile.
TalkAdvisor a Hexagonal Architecture example
Technically, our Hexagonal Architecture example is divided into two parts: the domain and the infrastructure. The domain contains all of the business logic and represents the recommendation bounded context. In contrast, the infrastructure contains:
- the Controllers
- the representation of our domain objects as REST resources
- the YouTube client
- and the Spring Boot Application.
Currently, TalkAdvisor has no implementation for the database layer, this article will explain why later. But the Repository implementations have been drawn on the schema to show where they should take place if they exist.
The domain and the infrastructure are both Maven modules. We can therefore use the Maven enforcer plugin to guarantee the sealing of the domain. This plugin looks for forbidden dependencies and fails the build if it finds any. Therefore, the configuration of the domain pom.xml bans all external dependencies.
As you can see, there are two exceptions here: Kotlin language dependencies, and the test scope. The latter allows the use of any framework for testing purpose.
NOTE: This last point may not be a good idea. The tests must be as sustainable as the production code because they are just as important. Making the test dependent on a framework like Spring prevents your domain from being technical-agnostic.
By the way, you can also avoid the use of Maven modules by using packages and the ArchUnit framework. ArchUnit helps you write tests to verify interdependencies between packages. It provides utilities to enforce the constraints of the Onion Architecture, a variant of the Hexagonal Architecture.
TalkAdvisor Domain
The functional area of TalkAdvisor is the recommendation of technical talks. Therefore, the main object here is the Recommendation, our domain aggregate.
Most of the packages of the domain represents the model of the recommendation bounded-context. You can find here classes such as Talk, Preferences, Topic…
The API
As mentioned above, TalkAdvisor has 2 features, the profile creation and the talk recommendation. Basically, the features are exposed by the domain through the API to the left adapters such as the Controllers. As a result, the API package contains two interfaces: CreateProfile and RecommendTalks describing the contract of our features.
Looking at their methods signature, you may notice that only domain objects — from the root recommendation package — are used here. This is how the API protects the domain from any possible leaks from the left adapters.
The TalksAdvisor class is the domain service implementation of our RecommendTalks feature and holds the main business logic.
In order to do its job well, TalksAdvisor must retrieve the user’s profile from the database. Then it can ask YouTube for talks related to his preferences and finally stores his recommendation into the database. These are services which do not belong to the domain. But they are provided to it via the SPI.
The SPI
The SPI is therefore composed of the Profiles and the Recommendations repositories, as well as the SearchTalks interface. They describe all the contracts for the services they provide to the domain.
The SPI also protects the domain from any leaks, by enforcing the use of domain objects in the right adapters. The use of interfaces here also makes the domain agnostic from the underlying implementations of the SPI. For example, SearchTalks is an abstraction of the YouTube client. The architecture offers the possibility of adding a new adapter in the infrastructure to support another platform such as DailyMotion. And it will have no impact on the domain. The repository interfaces offer as well the same level of flexibility.
The schema above describes the organization of the domain around the talk recommendation feature.
- The feature is exposed by the
RecommendTalks
interface in the API. Its input is the Criteria domain object and the output the Recommendation domain object TalksAdvisor
is the concrete implementation ofRecommendTalks
, the domain service. It will actually handle the request made to the domain.TalksAdvisor
passes the domain Criteria to theSearchTalks
interface of SPI, requesting an adapter to call an online-video platform to retrieve related Talks.SearchTalks
returns relevant domain Talks toTalksAdvisor
. The latter applies some business logic to these Talks to create a Recommendation.TalksAdvisor
stores the domain Recommendation through the Recommendations repository of the SPI.- Then
RecommendTalks
abstraction returns the Recommendation out of the domain.
As you can see, the domain only manages domain objects as inputs and outputs. Therefore it ensures the domain isolation from the infrastructure. The domain is self-contained.
The Tests
As explained in the A Test-Driven Implementation section, we usually start with the Functional Tests. The expected behavior of the features is described in the domain test resources in creating-a-profile.featureand recommending-talks.feature in our Hexagonal Architecture example.
Those files are the living documentation of our domain, they describe each feature with their related acceptance criteria. But they are more than just documents, Cucumber dynamically parses those files to create Functional Tests at runtime. It uses test building blocks named Step Definitions to trigger a specific action on each line of the feature description. Obviously, we need to provide these steps as they hold all the functional test logic of our application.
But unfortunately, this is not enough to make to make these tests work.
The Stubs
The point is, we are inside the domain, so we have no technical implementation of our SPI. In order to make those tests work, we provide a stubbed implementation from our third parties.
InMemoryProfiles, InMemoryRecommendations and HardCodedTalksSearcher implement the SPI interfaces by simulating the basic behavior of the third parties. They ensure the testability of the domain in total isolation. And it makes the domain reusable even if the right adapters implementation change.
You may have noticed those stubs belong to the production code and not to the tests. That’s because there is no database implementation in TalkAdvisor. We have a real YouTube Client, but we use the in-memory stubs in production. Because it was enough to validate our proof-of-concept. We are delaying the choice of the database technology to focus on the business logic. And we implemented the YouTube Client to provide real data to the user.
In the Domain, we only test real code
The usage of mocks through frameworks like Mockito is now widespread. Unfortunately, they must not be used to test business logic. A mock lets you to hard-code behaviors that may be functionally wrong.
The Mocking Caveat
For example in TalkAdvisor, we compute a duration category named TalkFormat from the duration of the Talk.
Basically here, you can see that a IGNITE
talk has a duration between 1 and 10 minutes while a UNIVERSITY
one has a duration between 1 and 4 hours. The Talk class ensures that its TalkFormat is always valid because it uses the TalkFormat#ofDuration
to compute it. But now, let's take a look at this "mocking" test:
Using a mock, we can in this test say that the duration of an IGNITE
talk is one hour, which is functionally false although the test passes. With that, we can build totally flawed business logic based on that test. This is not only a hygiene issue, because you actually freeze the tests on a particular version of that logic. And the refactor of the domain will not the fail the test! Even if the logic has changed and it's because the mock hard-codes it's own truth. That's why this still opens the door to potential functional bugs. No body reviews the existing mocks in all the tests when the behavior of the domain change, right ?
And what’s about the SPI?
Regarding the SPI, we already have the stubs to ensure the domain isolation which are reusable for the tests. So, no need to use mocks here as well. But this is totally fine to use mocks in the infrastructure tests.
Causing an error in the domain code may require the construction of complex domain objects. And this is sometimes not possible without the partial or the total duplication of the functional tests. Although it is recommended to avoid mocks within the domain tests, they can be freely used to isolate the adapters. In the test of your Controller, you only want to verify the DTO to domain mapping and the http behavior. Therefore, to avoid a unnecessary complexity in the tests of the infrastructure, the mocks can be used here.
For more information, a Hexagonal Architecture Testing Strategy is available on GitLab which is also described in the talk above 🇫🇷.
TalkAdvisor Infrastructure
The infrastructure gathers all the technical code which, by definition, doesn’t handle the business logic. Basically, this is where the right and left adapters are located. Unlike the domain, frameworks are allowed here. This is the reason why you’ll only find the Spring Boot framework inside the infrastructure.
The left adapters
By consuming the domain API, the main purpose of the left adapters is to expose the features to the consumers. Of course, the Controllers are of those adapters.
The REST layer
While the domain API is organized around the features, the controllers on the contrary are organized around our REST Resources. Therefore, it means that the concept of resource doesn’t exist inside the domain.
Finding the top-level REST Resources is easy, they are the persistent entities of the domain i.e. the domain aggregates. In TalkAdvisor, they are Profile and Recommendation. Accordingly, TalkAdvisor Controllers are ProfileController and RecommendationController.
The Controllers here receive the domain API (CreateProfile & RecommendTalks) by injection via the constructor. Before calling this API, the Controller is responsible for transforming the REST Resources into a domain object. You can either use a converter pattern or adopt a more Object-Oriented Programming approach like in our Hexagonal Architecture example. Looking at the ProfileController line 11 above, the Preferences resource coming from the request payload is transformed into its corresponding domain object via the method toDomainObject()
.
More than just placeholders, the REST Resources encapsulate the conversion logic into domain objects. And it works both ways. The RecommendationController converts the recommendation returned by the domain to its REST representation using the toResource()
method.
@PostMapping fun createRecommendation(@RequestHeader("User-Id") user: String): ResponseEntity<Recommendation> { val domainRecommendation = recommendTalks to user
val recommendation = domainRecommendation.toResource()
val location = linkTo(this::class.java).slash(recommendation).toUri()
return ResponseEntity.created(location).body(recommendation) }
But wait? toResource()
is belonging to the domain? Actually no. Here we use a Kotlin extension which allows us to add this method in the Recommendation of the domain.
This extension is defined in the REST Resource so it only concerns the infrastructure. Therefore the domain code will not have access to it. It’s a quite convenient way.
NOTE: Not using a language that does support extension ? You can also simply create a constructor in your REST Resource which takes the domain object as a parameter.
public Recommendation(DomainRecommendation domainRecommendation) { /* mapping logic */
}
The REST Resources
You may argue the interest of having two classes for the same object in the domain and in the REST layer. Why not serializing directly the domain object without annotating it?
Resource-Domain Asymmetry
First, because usually there is not a one-to-one mapping between your domain model and the representation as a resource. In the above example, the Recommendation in the domain has criteria while its REST resource does not. Since the criteria are for internal usage only, you should not expose them. Remove any unnecessary domain fields from your REST resource, if there is no reason for your consumer to read them. It will be counter-productive and will increase the adherence surface of your REST API, increasing the coupling with your consumers. By the way, the common criticism of REST around its verbosity is usually due to a lack of web API design.
To give you a better example, we can take a look at this other Hexagonal Architecture example: Columbiad Express. Columbiad Express is a Space Train booking system that will take you to the moon. Basically, the user performs searches given some criteria in order to choose the Space Train he needs. On the domain, one of the aggregate is the Search object.
The Search here contains a list of Space Trains for each bound of the trip (outbound and inbound). But if you take a look at the related Search REST resource, none of them appear here.
This is because Columbiad Express web API implements HATEOAS. The Space Trains related to the Search are represented as outbound and inbound links in the JSON serialization as follow:
The REST Resources as an anti-corruption layer
Finally, a straight serialization of your domain is prone to errors. Renaming a field or changing the structure of your domain objects will break your REST consumers. In that case, a class dedicated to representing the exposed resource, will buffer the changes. And allows you to refactor the domain as you need, without impacting your web API.
You can also see it as a way to avoid leaking of REST representation concerns inside your domain. Because the HATEOAS representation is really something you must avoid inside your hexagon.
The right adapters
On the other side lays the right adapters, the ones that are there to interface the domain with third-parties. As mentioned above, in this Hexagonal Architecture example, the repositories has been stubbed in the domain. So there is no adapter for it. Nevertheless, you can find our YouTube Client as an example of right adapter.
YouTube Client example
YouTubeSearchTalks is the concrete implementation of the domain’s SearchTalk SPI. Semantically, it offers the ability to retrieve talks from YouTube to the domain, respecting a contract defined by the domain.
The domain contract is defined by forTopics
in YouTubeSearchTalks line 12. Like the domain API, the use of domain objects is also enforced here (Topic & Talk). Our client converts the domain's Set<Topic>
into YouTube search options represented as a MultiValueMap inside the searchOptions
line 43. With it, we are able to perform a search request on YouTube using our restTemplate
line 23. As a result, the restTemplate
marshals the YouTube JSON response into our Kotlin representation . Naturally, this last class is specific to the YouTube API and is enclosed in the adapter.
Models translation
One interesting fact here is that the YouTube API has been designed such a way the search response doesn’t contain the duration of the Video. Since the Talk of the domain requires a duration, YouTubeSearchTalks makes another request via retrieveVideosDuration
line 15 to retrieve the VideosDetails. We can clearly see here the interest of the adaptation, the domain has only one Talk object while YouTube spread the same concept on two different classes Videos & VideosDetails. YouTube may have done this to avoid large search payload responses. We do not need a lot of details in TalkAdvisor, so having a single Talk class is totally fine in our case.
Finally, YouTubeSearchTalks converts Videos & VideosDetails into a set of Talk, lines 17 & 18.
val talksWithoutDuration = videosWithoutDuration.toTalkBuilders() return talksWithoutDuration.buildWithDurationsFrom(videosDuration)
Here we are using the same pattern as our REST Resources, Video has a transform method toTalkBuilder
line 8 to do the conversion.
NOTE: Because Video has no duration field, we can only create a partial Talk object from it, this is reason why its conversion is a pre-populated
Talk.Builder
. The duration will be added later from the related VideoDetails in YouTubeSearchTalks withTalk.Builder.withDurationFrom
line 46.
The left adapter as a functional resilience layer
As mentioned above, adapters are by definition anti-corruption layers. On the right side, they can also be a real functional resilience layer.
Fixing data quality issues
When a I was working for an online travel company, we were building a train booking system. Our software relied on a third-party service. This service provided us with the inventory of trains available between an origin and a destination. Unfortunately, this service suffered from a poor data quality. It was returning duplicate fares for the same train, sometimes some fares were missing for some travelers. Worse still, sometimes the reservation code was missing. So we were even unable to book some of the trains.
Fortunately, we applied Domain-Driven Design, so we checked the invariants of our domain objects in their constructors. It means if you try to instantiate a train without a reservation code, you will get an error. So our right adapter could not create a domain object from a buggy DTO of the external service.
In the first version of the adapter, if at least one of the trains was buggy, a domain error was returned to our user without any train. But we knew that the data quality issue usually concerned a few trains in the response. So, we decided to change our right adapter, to filter out any part of the third party response that was buggy. This way, the end-user could at least see the mappable trains instead of having an error. Our adapter also logged the unmappable DTOs as warnings, to track the improvement of third party’s the data quality.
Enabling an iterative product development strategy
This technique is not only applicable in case of data quality problem. You should know that when you book a train, depending on the carrier, you can choose between several ancillary products. Those ancillaries can just be a wifi subscription, a meal you have to choose from a list, a quantity of subway tickets at destination… The point was those ancillaries were really heterogeneous, and some of them could have their own business logic. So, we usually had to do a specific development for each of them.
In our MVP, we did not offer any ancillary to our users. Then the product management gradually asked us to integrate the most chosen ones. From a technical point of view, our third-party returned us a polymorphic list of ancillaries where the only common field was a code. With a mechanism similar to that described in the previous section; we were filtering out the unsupported ancillaries from this list.
However, this was not just a way to enable an iterative development strategy. Sometimes the carrier itself, far behind the third-party service we mentioned, could also add new ancillaries without any notice. With that mechanism, we were able to return only the ancillaries we supported to the end user instead of throwing an error.
The Application
At the end, the overall structure of our Hexagonal Architecture example looks like this:
The user interface and YouTube.com are obviously not part of our software. They have been drawn here to show the interaction of the infrastructure with the third parties. The only major part left is our Spring Boot Application: TalkAdvisorApplication.
As in a standard Spring Boot Application, the purpose of that class is the integration of all parts of the software together. On the infrastructure side, this is quite easy, the left and right adapters are annotated with the Spring Framework. But basically, for things to work, we need to register our Domain Service and Stubs in the Application Context. Because the RecommendationController needs an implementation of RecommendsTalks (TalksAdvisor) and TalksAdvisor needs an instance of Recommendations (InMemoryRecommendations).
You have two choices here, use a dedicated Spring Configuration for the domain to declare your bean factories:
@Bean fun profiles() = InMemoryProfiles() @Bean fun recommendations() = InMemoryRecommendations()
Or you can bind your domain with a ComponentScan using the technique described in the following article:
Now that we have everything packed, we can test the integration of our application as a whole. Taking back the previous schema, we simulate the user interface interactions by using the Karate test tool. On the other side, we are using WireMock as an external stub to simulate YouTube.com. We thus guarantee the speed, stability and isolation of our tests.
Concluding this Hexagonal Architecture example
This is the end of this detailed example of a Hexagonal Architecture implementation. Although we saw Kotlin samples here, everything is easily doable using Java as well (except for the extensions).
Our example is deals with a single bounded context. If you want to manage more than one, you must adopt a modular monolith architecture or divide each bounded context into a single micro-service. This way you’ll keep the sustainability strength of the Hexagonal Architecture.
Do not forget to take a look at our GitLab repository illustrating this blog post.
Originally published at https://beyondxscratch.com on August 23, 2020.