One legit way of refactoring a 10+ year old application into a more agile one is to break it up into microservices. If you want to try out microservices architectures, probably refactoring a monolith is your only option, since the most important reasons for choosing this type of architecture are very rare on our everyday level: no high load, no need for high scalability, no large developer teams, no fast data.
You don’t want to rewrite it
The drive to rewrite everything from the ground up seems acceptable. Nobody wants to read old, obfuscated spagetthi code. It seems easier to rewrite it then to understand and refactor it module-by-module, line-by-line.
No specification since it is already written
These kind of projects usually don’t have any specification, because the client tells you that everything that she needs can be found in the working legacy application. Go there they say and try it.
Well, of course you can, but a 10 or more years old codebase has a lot of hidden and not actively used and rarely accessed features. Think about a special margin setting that is hardcoded somewhere in the source code and just works for years. Nobody remembers it.
Until one day you deliver the rewritten printing functionality and the client tells you that you forgotten to apply that special margin in some special case. So where can you go for proper and accurate specification for this?
Only to the legacy spaghetti code that no one wants to read.
And a 10 year old application is fully packed of similar features. Hardcoded into view files, that noone knows, global variables (I point to you, PHP) and saved database procedures. That don’t event written anywhere.
So you never will finish the rewrite. It’s a vicious cycle.
No deliverables for years
This is much more problematic. Since you never finish the rewrite, all you have a staging environment with a growing, never used application. Maybe the client accepts the features with pointing out some missing cases and other features (see below), but no one uses the code. That means no real life testing.
The project starts to be the “let’s remove one-two developers from this, they have years/months to finish” project of your company, peoples come and go, nobody knows exactly what she does and for whom. Sometimes when the management realizes that nothing really happens on this project, they force the team to produce some dirty coded features fast to show something to the client.
You can refactor it later — they say.
The missing deliverables has a worse effect: new features must be implemented in both systems. The client pays almost 100 percent more for the feature.
Very angry client
After one year of variably intensive development the client will be very angry. No replacement of the originial software, double priced new features, slow pace. She just put her money into something that never pays back.
Shit legacy code at the end
And finally, at the finish of the project the code will be as spagetthi as the original was. Because of fast written last minute features, frequently replaced developers and broken window effect. At the end the management only quality requirement is “who cares, just finish this shit somehow”.
So no rewrite. Only refactor left
Oh my God, if you can’t rewrite the only option left is refactoring the legacy code? Extract methods? Write stragety pattern instead of switch/cases?
But who wants to extract methods from a 10 year old let’s say PHP code?
Nobody. Really. And if you try, you will definitely fail, because the old code won’t support new language features, so you should work in old, unsupported language versions probably on an unsupported os. For example, adding namespace and autoload support to an old PHP application took me weeks. If there’s no front controller pattern, so every request is routed via apache rewrite rules to flat php files, you have to add a
include('vendor/autoload.php') into every single fucking file. Then test them. All of them.
This is the point where mentioning microservices starts to seem reasonable. You take an old application, say PHP, moves it into a docker container, extracts every hardcoded configuration and services from it and you are ready to go.
Because in one month and with the help of a very good devops engineer, you will have a running legacy application. You can deliver this container to the client by replacing the current application with the dockerized one. Instant gains:
- A dockerized legacy app, that can be launched in any environments in minutes. this helps people start working on the project
- if the user management is well written, the legacy can be used as a so called API-Gateway, that manages sessions, users, roles and can be complemented with a json web token to enable microservices authenticate the requests and route them into the appropriate microservice.
- if the user management is badly written, you still have the whole working application in a container. This app can do everything that the legacy did, because it is the same in terms of source code. It always will be there to enable an existing feature with an added API endpoint.
When this huge legacy monolith service is delivered in a modern agile cloud ready (architecturally decoupled) infrastructure, you are ready to extract features from it into dedicated microservices.
It exactly works like the monolith refactoring but instead of moving features into modules, classes etc, you move them into separate services. Take for example the case of a flash application that should be replaced with a new one page app written in React, Angular, or Vue. This separate application can be written, pipelined and operated independently of the architecture or the legacy app as a docker container.
In an architecural standpoint this app once lived id the legacy codebase’s public directory as an .swf file. Now it is a separate application that runs on the same domain or a subdomain. The flash application used xml-s to communicate with the legacy application, so you have two ways to refactor this communication:
- force the new app to speak xml with the old application. But it’s not a long term maintainable idea since sooner or later you definitely move out the accompanied features from the legacy into a dedicated app
- implement an intermediate service (ACL, of anti-corruption layer) that accepts a well formed JSON from the new application and turns into that xml that the legacy speaks. This service is a so called Anti-Corruption Layer.
- …and later move all the needed functions out of the legacy into a new service that speaks the json version, so the frontend app can speak directly to it.
You can imagine how can you extract other functions, for example orders from a webshop almost the same way. This is how an important study on NGINX frames this process of extraction in a very helpful diagram:
If orders is module Z, then the process is:
- write automated functional of acceptance tests on the current monolith codebase (to check later that you don’t break anything)
- isolate orders module in the legacy monolith (it’s the hardest part if it is a spagetthi)
- create interfaces for both incoming and outgoing calls.
- rewrite the module in a separate microservice application
- use some kind of inter process communication for implementing communication between the legacy and the extracted module. This can range from simple HTTPS communication to messaging (in some cases event sourcing) depending on the rate of message exchange etc. But in a simple legacy HTTPS will be more then enough.
This process now can be turned into a steady workflow with frequent deployments using agile methodologies. This means that an architect can plan the next exraction(s) while the developers are working on one, so there’s no need to find out everything in advance. Because (in the case of a legacy application) that is simply impossible.
As Robert C. Martin, alias Uncle Bob says in his new book, The Clean Architecture:
The goal of the architect is to create a shape for the system that recognizes policy as the most essential element of the system while making the details irrelevant to that policy. This allows decisions about those details to be delayed and deferred .
So this way the monolith can be broken up into smaller microservices. These microservices shouldn’t be too small, because too small services need a lot of communication, and these small request-response cycles add up into a large timeout at the end, probably on the frontend. And too large means a new monolith so try to avoid it too.
These microservices can be delivered continuously from feature to feature. Happy client, happy pm, happy developers.