Have you faced a challenge to overhaul an existing service’s architecture for improved adaptability ? That’s exactly what we delved into when we worked on a tightly integrated service within the client’s system. We needed flexibility to integrate with different external systems and evolving technologies while keeping our core business logic intact.
While the title may have hinted at it, we chose hexagonal architecture as a structured framework to address our challenges and streamline our code. Hexagonal architecture, or ‘ports and adapters,’ is a design pattern focusing on separating a system’s core logic from external components like databases and user interfaces. This structure enhances modularity, making development, testing, and maintenance more independent and flexible. By implementing this architecture, we aimed to overcome the issues of tight coupling in our codebase, paving the way for a more maintainable and testable system.
Our journey involved building upon an existing microservice architecture, acting as the mediator between a shopping service and lender services. Even though things seemed pretty clear when we kicked off the project, a few weeks in, we were in for a rollercoaster ride of unexpected challenges.
Challenges we faced
- The input and output requirements were in a constant state of flux as dependent services were still a work in progress. This caused API calls to fail frequently.
- Fixing bugs took time due to tight coupling – The financing logic was tightly coupled with the data retrieval calls. Making updates to one, without disrupting the other one, was akin to untangling a complex knot. Fixing one bug could lead to introducing more elsewhere, causing system wide failures.
- Cascading data inconsistencies – In our system we had to assemble data structures consisting of data from multiple external sources. A failure or irregularity from any of these systems could spread into our system or a call to another system. We needed to devise a way to keep the data sanitary and consistent, so that it did not spread as a form of digital rot.
- Difficulty testing for multiple integration scenarios – each external service (lender services) had its own workflow, despite sharing a lot of common requirements. This resulted in redundant code and tests. Due to tight coupling with dependencies, there were increased integration tests. This slowed down the whole testing process.
The code was a maze of complexity, where a wrong turn could lead to significant consequences.
Enter hexagonal architecture: fits just right
As we wrestled with these challenges, the solution presented itself in the form of hexagonal architecture, also known as ports and adapters. This architectural approach offered a clear and compelling structure that aligned perfectly with our project’s requirements.
Taking inspiration from the above hexagonal architecture approach, we refactored our service to create the below architectural model for our service. Here’s a closer look at the components we incorporated into our architecture:
So how did the control flow for a specific external system (lender)?
- The shopping service would interact with our API endpoints to start the financing process.
- The Controller would receive the API requests and call the lender specific business logic in the Domain Layer.
- In the Domain Layer, we have the Business logic and the Providers.
- Providers would create requests based on defined contracts and make use of repositories to trigger API calls to dependent services. They would be responsible for data manipulation to return the response type expected for the business requirement. Data structure of the requests and responses would be managed using Entities. Each provider had a single responsibility to call one external system (lender).
- The Business logic would then use the Providers to create sequences of actions for each lender specific workflow. This is where the “if this, then that” logic was defined.
- Repository interfaces allow us to interact with external systems without tight coupling and ensure flexibility for future changes. HTTPClientRepository was one of its implementations for making HTTP calls to lenders. It handled exceptions as well.
Did it make a difference?
After implementing this architecture, we saw a big difference in software flexibility. Some of the benefits we observed were:
- Isolation of business logic: By separating the logic from the I/O contracts, we could make quick changes without affecting the core functionality. This led to increased readability of code. Dependency injection gave us the chance to keep the core business logic as clean and free of dependencies as possible. This modularity helped speed up the bug fixing process.
- Separating API contracts to external systems: Each provider only dealt with one external system (lender) integration. This leads to single responsibility for providers and makes it easy to keep track of the different integrations.
- Easier testing: Unit testing each component was made easy by mocking repositories and API calls. We did not need as many integration tests.
- Improved scalability: Adding new external systems (lender), changing business logic to suit more complex financing was quite seamless due to separation of logic. We could build upon the existing providers and repositories to make new workflows.
- Improved observability: When bugs were detected, logs allowed us to follow the transaction across services and reach the problem component quicker.
- Improved error handling: We were able to cover exception cases in each repository to avoid system wide failures.
However, it wasn’t all a fairy tale with happy endings. Along the way, we encountered our fair share of challenges, notably grappling with increased complexity and a significant learning curve for our developers that were unfamiliar with this pattern. It was necessary to educate the team on the project structure so we can maintain the architecture and ensure code remains well-organized.
Success is not final, failure is not fatal: It is the courage to continue that counts.Winston Churchill
Overall, the change made our project much easier to navigate.Hexagonal architecture didn’t quite fix all our issues, but the change certainly brought new life to the project. Our code was significantly more readable and adaptable. We loved working in it a lot more with this new structure of separating the domain model from the integration infrastructure.