Most software products will have at least one codebase that developers avoid because they are terrified of breaking it, but how do you go about fixing it?
First things first, the undisputed textbook answer on how to deal with legacy code is Working Effectively with Legacy Code by Michael Feathers. It takes a pragmatic approach to cleaning up your codebase through the use of test and incremental refactoring, and contains many techniques and examples. I highly recommend reading it.
We have three options when faced with the challenge of updating a traditional legacy system:
As developers, when faced with a big ball of spaghetti mud, we’re tempted to re-write the entire application from scratch rather than try to fix the old one. Fixing a large legacy system using the techniques and disciplines described in Michael Feathers’ book could take years of effort by skilled engineers, but we’re sure we could rewrite the gorram thing in a few months if we could only use SuperFramework v4.2!
What we often fail to take into consideration is non-technical or operational:
The above list only mentions some of the known unknowns, and is by no means exhaustive. In an endeavour like this we will always discover unknown unknowns that will further complicate the rewrite. Usually rewriting a system is an expensive approach, and should be used as a last resort.
Risk is the reason we don’t want to touch our legacy code. You don’t want to be the reason why your company loses $40 000 an hour because of a faulty and hard-to-read
if statement. Risk is also the reason why we should touch our legacy code. Bad code leads to more bad code, and unless you actively take action to improve your legacy code you risk ending up in an even worse position.
Luckily, there are a few modern tools and techniques we can adopt to mitigate some of the risk that come with legacy services.
Before starting to rewrite and deploy a legacy application, we would like a safety net that will catch us if we accidentally deploy a bad build. Of course, we hope that we won’t ever have to use this safety net but we will feel a lot safer knowing that it’s there. This safety net is being able to quickly and easily deploy and revert to a previous build. To be able to do this easily we will have to spend some time developing a deployment process and a release pipeline. The end goal in continuous delivery is being able to do a release in one step, and a deployment in another.
Suppose you work for a business that has a large legacy service in production. How do you know just how well your legacy service is holding up? Gut instinct? The number of rants you overhear from the developers? Do you go by the number of bugs in your issue tracking system, or perhaps by the number of support requests that come in? The issue with all of the above is that they all depend on a source external to the application itself to tell us that something is wrong. With an error monitoring tool like Bugsnag we get notified in real time whenever our application behaves unexpectedly. Error monitoring comes in really handy when checking for regressions. Bugs can be marked as fixed, and if they reappear we get notified about the regression and should perhaps rollback to an earlier version. Similarly, either by using as-is or by integrating Bugsnag with your favourite issue tracking service, you can monitor the progress of improving your legacy codebase.
Although a great tool for managers and non-technical people, developers get the most out of using Bugsnag. For any error that occurs, Bugsnag will show you where in the code, when, how often, and which platform it happened + more. Good luck getting all that in a support request.
Suppose you work for a bank that wants to implement a feature to allow business analysts to see how many new customers sign up for each type of bank account. Suppose further that you have a legacy service that does all the heavy lifting of validation, transaction handling and account management. One would think that because we want to record new signups, we would have to write our new code in the account management section of the legacy service. What we can do instead is to make a new microservice; a business-analytics service. This could for example receive requests from the legacy service upon a new customer signup, or periodically monitor the database for new entries and in that way be completely decoupled from the legacy service (but coupled to your database). Either way, the change required in your legacy service is minimal or non-existent. The keen observer will notice that this above example is the architectural equivalent of the Sprout Method/Class technique described in Feathers’ book.
Microservices work the best when developed from an existing monolithic architecture where the domain is well understood, and therefore will often lend itself well to migrating legacy services. That being said, microservices are no silver bullet, and by no means are they a quick fix. There are drawbacks and considerations you will have to take into account before rushing into this style of architecture. I highly recommend Sam Newman’s Building Microservices if you are interested in learning more about this exciting subject.
Legacy code is technical debt that must be managed in one way or another. The recommended approach is still following Michael Feathers’ suggestions, but in recent years new tools and techniques have emerged. Take advantage of these, and take control of your technical debt before it controls you.