Intro
Architecture patterns are like Ogres. They have layers, make you cry, and if you leave them out in the sun, they get all brown, and start sprouting little white hairs.
Also like Ogres, architecture patterns with a layered focus have been around in some shape or form since story book times. Are these patterns still relevant in the days of hyper-scalers, serverless development, and event-driven microservices? Can we extract the goodness from them and apply it to Integration Services and not just traditional applications? Thankfully, and somewhat unsurprisingly given this blog exists, the answer to both these questions is a resounding “yes”.
The Progression
There are too many layered architecture approaches to cover in a single blog. The purpose of this blog is not to get too bogged down in the details (much as an Ogre might), and instead distill one or two practical points from these time-tested patterns and explore how they may apply to modern Integration Services deployed to The Cloud ™.
Observant readers may have already guessed which three layer-based architecture patterns we are going to squeeze for their juices from the title of this blog. They are: Hexagonal Architecture, Onion Architecture, and Clean Architecture. These three similar-but-different patterns all build on a core concept and then add their own specificities and nomenclature to confuse anyone trying to distinguish between them.
Hexagons Don’t Always Have Six Sides
In a statement that would make geometry and linguistics teachers quiver, hexagonal architecture has nothing to do with the number six. In fact, its nom de plume “Ports and Adapters” goes a lot further to explaining the pattern, but is much harder to draw.
Hexagonal Architecture defines the core that the other two patterns subsequently build on. It carbon dates back to 2005 when it was introduced by Alistair Cockburn.
At the heart of the hexagon is the idea that the guts of your application (or Service) cannot depend on anything external, such as infrastructure, data repositories, or even user interfaces. The core has a concept of the external dependencies in the form of interfaces (ports), but is not aware of how those dependencies are realised.
For example, my User Service knows it needs to source customer data from “somewhere”. We can create a User Repository interface with method signatures to retrieve and save user information. This interface acts as the Port. Adapters then implement this interface to provide the infrastructure-specific logic. I might have an in-memory database adapter for integration testing and a PostgreSQL database adapter for when I go live. The key is that I can create adapters for different databases or other storage mechanisms without affecting the domain logic at the hexagon’s core. We then can repeat this concept with all external dependencies on our service, such as our event handlers or producers and our configuration ports.
Layering on the Onion
Fast forwarding to 2008, along comes Jeffrey Palermo with a four-part blog describing Onion Architecture. Although he proposes it as an alternative to traditional vertical-layered architecture rather than an evolution or refinement of Hexagonal architecture, he acknowledges it shares the same core principle.
Alistair Cockburn has written a bit about Hexagonal architecture. Hexagonal architecture and Onion Architecture share the following premise: Externalize infrastructure and write adapter code so that the infrastructure does not become tightly coupled.
Jeffrey Palmero
Onion Architecture goes further though and adds layers to the core with the rule that each layer can only depend on more central layers, and the most central layer – the domain model – only depends on itself.
There is no strict rule to the number of layers with Onion Architecture, but the standard model is usually drawn with Domain Services (repository interfaces, etc.) and Application Services (driving interfaces) wrapped around the core, and then the external dependencies on the outermost layer.
Onion Architecture is arguably more complex than Hexagonal Architecture and lends itself to enterprise grade applications. Strictly applying this approach to lightweight Integration Services is likely to provide additional effort for little benefit. However, we can still appreciate the principles of inward dependencies and externalising infrastructure.
Cleaning Things Up
In the most recently added architecture approach on our list (although still over 10 years old), Uncle Bob refined the Onion by renaming and clearly defining its layers in his description of Clean Architecture.
With Clean Architecture, the core layers of Entities, Use Cases, Controllers, and Frameworks are clearly defined. Details go on the outside, and things become more and more abstract as we head towards the core.
Clean Architecture is very similar to Onion Architecture – in fact I have read some blogs which equate them. And as with the other patterns, all dependencies are inwards and infrastructure is externalised.
Extracting the Good Stuff
So, three architecture patterns, all focussing on something that looks roughly like a circle (you might need to squint a bit with the Hexagon). How can we apply the best bits to smaller integration services without getting too caught up down in what is essentially rather heavywieght enterprise application architecture?
Externalising your frameworks…
It might be easy to assume that since you’re deploying a service to one of the large cloud providers, you get this one for free. In actuality, it’s one you probably want to pay even more attention to. Each cloud provider has their own specific ways of doing things which we don’t want to leak into our service logic.
Consider a service built on AWS, favouring a serverless lambda function deployment. The lambda handler function is going to deal with very AWS-centric data structures. Your service might need to interact with Secrets Manager or Parameter Store to extract some environment details. It may publish an event or message (yes, these are different things) to SNS or SQS. An RDS database may be involved. Objects might whiz off to S3. The list goes on.
If we apply the core principles of these various patterns to this scenario, we treat the AWS specific services as external dependencies and thus the interaction with them sits on the outermost layer of our service. The core service logic doesn’t care where the request originated, or how its secret adapter is going to fetch the credentials it needs to interact with some repository of information. It just applies the required service logic, assuming that these boring details will be taken care of by one of the outer-layer proletariat.
… Makes your service independently testable …
A key benefit of this approach is that the service logic is independently testable. We don’t need to worry about where we are hosting the service. You can test the business rules and service logic without having the right version of a database spun up in a container. We don’t have to roll-our-own AWS event object to pass as an input into the service. We don’t have to stub out the entire AWS Secrets Manager API to intercept requests. Instead, we are free to focus on testing what the service is supposed to be doing, rather than how it goes about it.
… And independent of infrastructure
Articles tend to highlight this benefit because of its ability to swap out components as you see fit. Don’t like PostgreSQL? Throw in a SQL Server adapter. Need to publish your events to Kafka now instead of Kinesis? Not a problem. Want to invoke your service with a GraphQL client via AppSync instead of just an API Gateway? Easy peasy. It’s just a matter of writing a new adapter and conforming to the interface.
Another benefit, though, is the ability to lift and shift your service completely out of one cloud and into another. True, there will be a bit of effort writing new adapters for the way Azure does things instead of AWS, but only the outer layer should need to be replaced. The core and more complex service logic remains intact and carefree of where it is running.
Outro
Architecture patterns are like Ogres. Good ones always come round again in sequels, prequels, and spin-offs. With integration architecture, we don’t have to reinvent the wheel, but we also don’t want our architecture to bog down our services. By applying core principles from established patterns, we can create services which are portable, testable, and flexible while remaining lightweight and manageable.