Incremental Architecture

Posted by Chris Turner on Sun, Jan 29, 2023

I have been having some interesting discussions with colleagues around evolving architecture and adding new requirements in an agile environment. The discussion raised some interesting points that I felt worthy of a blog post exploration.

Background

Imagine a simple system comprised of two main components:

  • Component A is the main component containing the key business logic. In order for it to do its job it needs to interact with component X. Mostly it needs to retrieve activity status from X, but it can also initiate a new activity from time to time.
  • Component X is a legacy system outside of our control. It has a number of peculiarities that make it challenging to integrate with. These include:
    • Legacy format API payloads that aren’t mapped to a domain model
    • Payloads that might not always be backwards compatible
    • Release cadences that aren’t related to those of component A
    • Periods of scheduled non-availability where some requests cannot be accepted and must be retried later
    • Performance constraints requiring some throttling of requests
    • High latency on some operations

Given the challenges of interfacing with component X, the team building the integration made a sensible decision and decided to keep all the complexity outside of the main component. Instead they added a new microservice, Component B. This component implements an adapter and anti-corruption layer that deals with, and abstracts away, all the eccentricities of X.

Component B has its own build and deployment lifecycle so that it can change in line with X, rather than trying to couple releases of A and X together. Communications between A and B are implemented asynchronously, using message queues, to further decouple A and X and address the latencies, non-availability and throttling.

This simple architecture model can be show as:

-------                   -------      -------
|     | ---> |Queue| ---> |     |      |     |
|  A  |                   |  B  | ---> |  X  |
|     | <--- |Queue| <--- |     |      |     |
-------                   -------      -------

New Requirement

Next, imagine there’s a new requirement for component A to implement an additional piece of business logic. This new logic needs to use another piece of functionality from legacy component X. In this case it needs to reserve a resource from X and then later either release or consume that resource.

It’s important to note that the new business logic is completely separate from the existing logic and should be treated as an entirely separate domain concept. In component A, the new logic would most likely be implemented in a separate module (or maybe even in a different component altogether).

Architecture Considerations

The discussions commence around how to add this new requirement into the existing architecture.

Code

Firstly, there are options about how we incorporate the code for the new integration with X…

  1. Extend the existing implementation in component B to add the new integration directly into this code
  2. Duplicate the code in component B into a new module and refactor this module to implement just the new integration
  3. Extract the generic code in component B into a common library then implement the two integrations as separate modules using this shared library

Option 1 gives the quickest delivery increment for the new integration, but creates tightly coupled code with low cohesion that will need an amount of refactoring in order to be easily maintainable
as component X evolves - especially if one integration’s semantics changes while the other does not.

Option 2 takes slightly longer and results in a lot of code duplication. However it does achieve two very loosely coupled modules with reasonable cohesion. The code duplication may become problematical if the interfaces to component X change any of their underlying semantics, as code will need to be changed in two places: slowing down future delivery if this becomes necessary. However, this might be a good stepping stone to Option 3 if the commonality between the two integrations is not completely clear.

Option 3 takes the longest, as extracting and creating good shared libraries is hard. If the library is done well then although both integrations are coupled to the same shared code they will be able to evolve semantics independently. However, if the library is done badly then we experience all the same pains of option 1, but having spent much more time to do so! Done well, this option creates two loosely coupled, highly cohesive modules along with a cohesive library of shared utility functionality.

In our target architecture, option 3 is our preferred outcome. This has the best structure and is going to be the easiest to maintain and evolve going forward. However, it is by far the most difficult to achieve if we start out with this as the direct deliverable goal.

Deployment

We can also consider choices around deployment…

If we code using option 1 above then there isn’t really a deployment decision: the new code just gets deployed as part of component B.

However, if selecting code options 2 or 3 then we have the choice of either deploying both modules within component B, OR creating a whole new Component C, that contains the new integration module and which is deployed separately.

The best deployment approach depends on a whole bunch of factors, including things such as:

  • Memory size and processor usage for the modules
  • Needing to scale the modules independently or not
  • Different security constraints on the modules at an infrastructure/networking level
  • Deployment life-cycles and team ownership for the modules
  • Common architectural principles and patterns across the wider system (e.g. monoliths or microservices)

In general, creating new deployment components usually takes more up-front time to generate and configure than adding to an existing deployment. However, the extra work of creating a separate deployment may pay dividends longer-term due to scalability and flexibility benefits.

Our target deployment architecture is one of multiple loosely coupled microservices that can evolve and scale independently, so having a way to easily create separate deployments for each integration is very important.

An Incremental Approach

So, given all of the above options and choices, what’s the best agile approach for adding this new requirement? Can we deliver something early to allow us to verify/de-risk the approach and allow us to gain learning? How do we do it without corrupting our architecture or leaving an unmaintainable mess of tightly coupled code? How do we ensure the end product meets production-level resilience?

The answer to all of the above questions is to adopt an incremental approach. In fact, we actually discover that we can use all of the less desirable options we described and use these a stepping-stones to helping us more reliably deliver against the business requirements AND our target architectural model:

  • In the first increment we go for code option 1 and just add the new functionality to the existing code in component B. This gets us a working solution quickly and allows us to verify the solution and learn more from having something that works early in the project.
  • In the second increment we then adjust the code for things we have learnt and add further resilience. We may also move towards code option 2, particularity if the commonality between to two integrations is not completely obvious or there is still uncertainty around how they might differ. We would then reach the position of having two separate modules with some chunks of duplicated core code, but each supporting only one of the two integrations. Both modules are deployed in component B.
  • In the third increment, once we have enough knowledge and feedback, we can extract the core code that is identical in both modules into a well designed library (adopting CUPID properties), and then refactor the modules to use this shared library. Again, both modules are deployed in component B.
  • Finally (if necessary) we can add an increment to spilt the modules into separate deployments in components B and C, which should now be relatively easy given they have a shared common library and are completely decoupled from each other. Alternatively we can leave this last step until the time when it becomes necessary to actually deploy the two modules separately.

This incremental approach gives the quickest initial delivery of the new requirement, allowing early learning around anything we might have missed. The later increments then allow gradually improving the code, architecture and resiliency into something that is much cleaner and more maintainable for the future, as well as being more resilient and easier to scale.

One often cited risk associated with incremental approaches, such as this, is that project timelines/delivery pressures on the engineering team result in skipping the later increments. Working software has been delivered in the first increment, so it must be done, right? Here lies the path to “Technical Debt”, fragile systems, unmaintainable code and greatly reduced delivery capability the next time anyone wants to change or add functionality associated with component X.

Early increments are about de-risking and learning whether we are building the right thing. Later increments are about incorporating this learning and building deliveries that are fit for the future. Only as a whole do they deliver a ‘production quality’ solution. The whole first increment / MVP / production delivery approach is its own complex topic and I will be posting about this separately.

Delivering incremental architectural change is by far the best approach for early learning and de-risking as well as building more maintainable systems. Later increments allow you to incorporate learning and ensure resilience and maintainability. Just make sure your processes allow the all important later increments to take place in a timely manner.



comments powered by Disqus