Hexagonal architecture or the Ports and Adapters architecture, is a way to structure your application such that its isolated from infrastructure level concerns. It exposes what’s called Ports (or interfaces in C#) that lay down the contract for what the domain supports for e.g. a port for loading products. Adapters then are simply the implementation of these ports using a certain technology stack. For e.g. load products from an Oracle database or from a web service etc.
Many articles talk about the what of this style but in my view, not enough talk about the how. In this post, I am going to try and show one way to actually structure the solution to be more in line with the hexagonal ports and adapters ethos. This directly comes out of recent refactoring effort we made in our team to refactor one of our production services.
I work for an e-commerce company on a team where we handle the backend restocking of our warehouse. These restocking decisions are data driven. We process around 30k+ products that our domain is directly responsible for managing, out of our entire assortment, everyday and calculate their reordering levels. These processed products are then sent via asynchronous messaging to another service, that runs through these products and converts them into purchase proposals for restocking those products. Its a fairly distributed architecture with around 6 services involved in the entire end to end flow.
The reordering level calculation although fairly simple relies on a lot of data, most of which lives in our central Oracle database and some lives on our Raven cluster (both these stores are on-prem while all our services are on AWS).
The first version of the service architecture looked like this:
The use case in our domain will reach out to various “services” to gather all sorts of data from various sources and then enrich the base product with it. It will then perform the reorder level calculation for each product and publish it out to our downstream service via Amazon SNS – a publish subscribe mechanism.
We structured our solution to have technology specific adapter assemblies to such an extent that it was difficult to tell simply by looking at the structure what this service did. It became all about the technology stack and the domain kinda got overshadowed.
The orchestrating use case in the domain model looked somewhat like this:
A lot of code has been removed for brevity (and service names have been dummified), in real life this use case class was more than 200 lines of C# code.
Because of the way the data is structured and set up in Oracle in our organisation, we had created a port interface around each of these queries and added those as the dependencies of our use case. The domain layer got filled with lots of these little internals specific interfaces, IThisService and IThatService. Classic Conway’s Law in action.
Things got interesting when a couple of weeks ago we had to add more queries to the service and we started off following the same pattern. Alistair Cockburn, the inventor of Hexagonal architecture, advocates for maximum 4-5 ports per hexagon but with these queries we were well on our way to have 8.
What if few months from now we have a different data storage engine for e.g. AWS RDS and we no longer have a need for multiple queries?** . Perhaps we’ll find a way to optimally retrieve data with just one or couple of queries? We will then not only have to write RDS specific adapter assembly but also either eliminate a bunch of interfaces from our domain layer or litter the RDS implementation with a bunch of unimplemented query methods. Violates Interface Segregation Principle.
This would also have a knock on effect on our use case which orchestrates all these operations. Its completely contrary to the idea of the Ports and Adapters style. Use case doesn’t care that you need to run 17 queries in order to get the data, its job is to execute business rules of your domain and that’s what makes you money. Accessing data for a specific function is the adapter’s job.
We realised we had to refactor*** a service that’s already in production to eliminate this kind of “abstracted coupling” and maintain the testability of the service all the while simplifying the use case.
One of the key insights of this style of architecture is that the ports and by definition the adapters are for carrying out a specific domain function and they are never about the technology itself. So the fact that we needed to execute these half a dozen queries to get all sorts of additional data in order to eventually calculate reorder level, is an internal detail of a much coarser operation i.e. get active products. So why not encapsulate all that complexity behind a single port i.e. interface? Think the Facade design pattern. So that’s what we did!
We moved all these mini data services into the adapter that retrieved active products and because all the additional data belonged to the product entity anyway, it made sense for it all to be unified behind a single interface. We still kept the data methods in separate classes for maintainbility but they were no longer implementations of their own interfaces.
The use case then only gets a collection of fully hydrated products that it can then calculate reorder levels for. Getting rid of a lot of faux ports resulted in about 75% reduction in code volume in this class alone.
The new structure post refactor looks like this:
We also got rid of technology specific adapter assemblies because they can very quickly grow out of control. Its much simpler to just create technology specific folders within an adapter that’s structured by the function it serves. Although, if you have a good enough reason to have separate assemblies and can retain testability and domain function visibility, then by all means have separate assemblies. Having dozens of technology specific packages in one assembly is also bit of a problem.
We also refactored the domain namespace a bit to tidy it up and make it’s structure more meaningful:
The solution structure now reads much better, you can get some idea about what the application does, just by looking at it.
The port/interfaces follow a role based naming style for e.g. IRetrieveActiveProducts rather than IActiveProductsRetriever. This is just a convention we are trying to follow on my team because it reads more naturally.
Our tests became more about the behaviour and use case rather than lots and lots of internal specific unit tests:
In order to fulfill the dependencies for the tests without worrying about hooking up any real databases, I can now simply create stubs or test adapters and substitute them in for the real adapters and be able to assert on the behaviour:
Ofcourse architecture evolves throughout the life of an application as we attain a better understanding of our domain over time. This is in no way the culmination of ours either but only a milestone along the way. The important goals to keep in mind to help evolve the architecture in a meaningful way are:
- Testability of the beahviour.
- Isolation from the infrastructure. and,
- Fast feedback loops to see what works and what doesn’t.
Hope this post wasn’t all gibberish and that there was a helpful takeaway or two for people.
Although you don’t often change technology stacks willy nilly but it puts the business you work for in an incredibly powerful position to know that they can change and adopt better technology rapidly. We have been ditching RavenDB in favour of DynamoDB as a part of an ongoing migration effort on our team only because much of our service architecture has been designed to allow for that kind of agility. This refactoring experience report is one example of how we do it and how Ports and Adapters style fits the bill.
By contrast, I have also worked on teams where the application was so tightly coupled to the underlying data storage/infrastructure, that this kind of refactoring effort will have taken weeks if not months to finish and it would have had a high probability of failing on day one.
Refactoring a production service is no small task so its better to do with the rest of the team in a mob-programming style. With people watching over you, you will be surprised the kind of simple things they will catch that you will overlook. In my experience there is no better way of sharing knowledge in a team and learning from each other, than mob programming. Worth doing it. We were able to refactor the service’s entire internal structure in less than 4 hours and went to production successfully soon after.