How to rewrite legacy applications or split monoliths into microservices without slowing down on delivering features or introducing bugs in your system.
The need to rewrite whole or parts of software arises quite often. Be it legacy modernization where an organization plans to move from monoliths to microservices, or parts of the application have become so complex and difficult to extend that they call for a rewrite. With the modern pace of software development, changes are the rule rather than the exception. What was correct yesterday has to be revised again tomorrow.
But rewrites can be challenging:
They may lead to bugs in an otherwise working software, which could result in revenue loss and customer drop out.
They do not add value to the users, and thus are difficult to prioritise over features which do so.
Nevertheless, continuously improving the maintainability of code is of prime importance. The key is that we should be able to make such changes flexibly and safely without disrupting ongoing operations.
In the rest of this post, we’ll go through an evolutionary approach to rewriting applications, which can navigate through the challenges mentioned above.
0. The Initial State
Our sample application has three modules: inventory, pricing, and shipping, and we’ve identified that pricing needs to be re-written. The Pricing API has four fields: price, discount, tax, and total price for a product.
1. Adding a new module/microservice as a proxy
In the first step, we create a proxy module. It simply forwards requests and responses to and from legacy pricing, but will later expand into a full-fledged replacement to it.
2a. Partial rewrite (with errors)
We start implementing the logic in the new module, and target the tax and total price calculation first.
The crux of this approach is a small sub-module in the new code, which we call the merging utility. It has three responsibilities:
Merging the responses from the legacy pricing and new pricing.
Defining the rules of discrepancy resolution, which defaults to using values from legacy pricing.
Reporting the discrepancies.
In this case, the discount was not reduced from the total price, which led to an incorrect total price being calculated in the new module. The merging utility picked the correct values from the legacy module and reported the discrepancy.
The limitations of unit tests
Writing unit tests is a manual process, and we may understand the requirements incorrectly, when rewriting existing code. That may lead to a wrong or incomplete implementation being unit tested, and subsequently bugs in a feature that was working perfectly earlier.
This is where the merging utility shines. Because of its capability to compare values in production and output correct values in case of conflict, it gives us the confidence that, even in the worst case, our application will behave as expected, and provides us the list of problems we need to fix.
2b. Fixing the errors
Once the discrepancy shows up in our monitoring tool, we can easily fix and add a test. It subsequently disappears once the fix gets deployed to production.
3. Adding new features in the partially written new module
Before we could rewrite the whole pricing module, we were given a requirement to add the tax percentage to the response.
We simply extend the new module to add tax percentage, and it will be available in the final output. We do not need to make any changes to the legacy codebase to achieve the result, and the change can be made even if the rewrite is not yet complete.
4. Changing Existing Features through the partially written new module
Let’s say that now the tax percentage needs to change from 19% to 16%. This change impacts the tax amount and the total price as well, and will lead to a divergence between the responses of legacy pricing and the new pricing.
To handle this, the merging utility takes a list of fields to be taken from the new pricing, regardless of discrepancies. In this case, the fields are tax, tax percentage, and total price. Accordingly, the merging utility will not report these differences as discrepancies.
5. Rewriting the remaining parts
Now that we have delivered the critical features and have some slack time, we can go back to rewrite the remaining parts.. In this example, it would be writing the logic to calculate the discount as 10% of the initial price. Assuming that we write the correct logic (and don’t forget to write unit tests), the remaining discrepancies will go away from our monitoring dashboard.
6. Cleaning up and the final state
After a few days of observation, if there are no new discrepancies shown by our monitoring dashboard, we can remove the merging utility, cut the cords to legacy pricing, and delete the legacy pricing code.
With the approach, we can rewrite complex codebases as trouble-free as possible. It allows the rewrite to be parked when we have critical business requirements to be implemented, and resumed when our team has more bandwidth, and thus gives us the security of being prepared for upcoming challenges.
We have rewritten code-bases of different sizes and complexity a couple of times, without putting any business features on the back burner, and without a need for any kind of extra testing. We were able to achieve zero bug rewrites. Continuous Migration, happy teams, happy businesses.
Disclaimer: The statements and opinions expressed in this article are those of the author(s) and do not necessarily reflect the positions of Thoughtworks.