So, you’ve been tasked with making a change in a legacy code base. Maybe you need to pass a new field through a tech stack that you are not familiar with. Or perhaps you want to kill off an old feature once and for all. Whatever the reason, you know you will be sailing into the unknown. The waters are murky and you may find yourself surrounded by classes whose line counts run into many thousands. Hundreds of files are organized into modules that made sense many moons ago, but these signposts are no longer helpful. Developers who have traveled these seas before you talk in hushed voices of sandbars where their ships have run aground and what wrecks lie sleeping beneath the waves. There are no stars in the sky that you recognize. You, brave soul, will sail onward.
As daunting as some of these systems may be, we cannot let ourselves be so afraid that we avoid maintaining legacy codebases. While we work to draw up new maps, strangle these beasts, and let loose a fleet of sleek microservices, this monstrous old code can be an important part of established tech companies. While these code paths still live, we will do our best to support new use cases, create positive customer experiences, and cut away at any parts that we can pull into new microservices. Development must go on until that legacy code sails off into the sunset.
Make No Assumptions
When you are working within a legacy codebase, make no assumptions. This code was beautiful once, but parts are starting to smell. Field and method names may have matched their uses at one point in time, but now they do something completely different. Things may be interconnected that you don’t expect. Everything will have a side effect. Some data will be null when you thought it couldn’t possibly be null.
This can seem scary, but you can code defensively once you realize that you can’t make any assumptions:
- Ask questions! Find a domain expert and have them do a quick walkthrough of the area on which you’re working. They have sailed these waters before and have made many maps, even if those maps are only in their head. They know where some of the hazards are and would love to share them with you so you don’t have to find them yourself.
- Names (Fields, Classes, Methods, etc) are guilty until proven innocent. That “getter” may be causing a value to be updated. Something called TaxTotal may be completely different than its neighboring field TotalTax. Take care when naming things and avoid overloading existing data with meaning that it was never meant to capture.
- Null check all the things, especially the parameters.
- Trace the usage of the methods more levels up than you normally would and then investigate the implementation of called methods, too.
- Be skeptical of comments; they may no longer be accurate. Consider cleaning up comments and strive to contribute expressive code that doesn’t require them in the first place.
Approach the code from the perspective of someone wanting to learn, not someone on a deadline.
Be sure to explore! The best way to prevent assumptions is to get to know the application. Before writing a single line of code, dive in with your IDE and learn about how the code is connected. Approach the code from the perspective of someone wanting to learn, not someone on a deadline. Find a way to make it fun, like challenging yourself to diagram how classes fit together or creating a sequence diagram to share with your team. An application can be a lot more approachable when you set sail as an explorer hoping to map new lands and discover new species.
Feature flags are your new best friend. There are many open source feature flagging systems to chose from and one may already be integrated with the legacy codebase you are working with. I like to put flags around everything from logging to new features and deprecations.
Feature flags serve two purposes here:
First, it’s easy to turn off your code if something goes wrong. This is an effective rollback strategy and has saved me from a more complicated rollback many times.
Second, feature flags can help you launch exactly when you want to. Many legacy codebases are released on a regular schedule and you may need to wait weeks or even a month for your code to go out. You can get ahead of the game by putting a code change behind a feature flag now, rather than waiting on other dependencies to be ready. By the time you are done working on other codebases needed to implement the feature, you can turn it on in a legacy codebase with the click of a button.
As much as I like feature flags, there are some downsides to using them. Feature flags can add to code complexity and sometimes cost network calls. When feature flags are not maintained regularly and removed when they are no longer needed, they can become just another piece of tech debt.
Don’t forget to clean up those feature flags after you successfully launch your code changes, lest they become yet another hidden danger waiting to snare the next developer traveling these waters. When you set up a new flag, immediately write up a task or todo to remove the flag so you don’t forget.
When approaching an unfamiliar legacy codebase, sometimes the best way forward can be through refactoring. Cleaning up dirty code can help you learn about what that code does and leave it in a better state for the next developer. See a sea monster? Slay it! Or at least cut off a few limbs.
One of my favorite ways to refactor is taking methods that are too long and breaking complex functionality out into new shorter methods. Some IDEs even have helpful tools for extracting methods from existing code! With proper naming, this can make code more readable and prevent duplication.
It’s also really important to clean up dead code. As requirements change and companies grow beyond old code paths, we are left with code that should no longer be called. Sometimes, you can delete entire classes or methods that are no longer being used. Also keep an eye out for smaller things like code branches that should no longer be reachable or parameters that are no longer used. I celebrate lines of code deleted just as much as lines of code added.
In an industry that relies on automated test suites and is moving toward one click releases, manual testing is a bit of a taboo. But let’s be real, if you’re working in a legacy codebase, you may be working with an application that was born when there were armies of QA engineers and slow release cycles.
There may not be unit tests for some of the classes that you need to change. Be a good citizen and write some, even if it takes most of a dev day. You’ll thank yourself later when it prevents someone from breaking your feature in the future.
It’s very likely that there aren’t sufficient automated high level integration or feature tests in the legacy project that you are working on. In this case, you may need to find other ways of doing this testing.
- Always start the application locally. Try a variety of real calls, not just the one that you think is targeting your change.
- If the dev/release process allows, try out your code in a testing environment before merging your code to master. This lets you to see how your changes interact with the whole ecosystem.
- Test your application as a user going through UI flows. If you’re not sure how your backend endpoint gets called, ask a domain expert what they like to do when testing.
- Invite others to interact with your changes. This can include everyone from team members to product owners. Not only is this a great way to share the testing burden for a large project, it helps familiarize those around you with the expected changes.
Familiarize yourself with how the codebase you are working in is monitored and sign up for any alerts or lists that keep you in the loop about problems. When your change is released, it can be advantageous to keep an eye on reporting related to the area you are working in. You want to be the first to discover any potential issues because otherwise it can take a very long time to unravel why a monolith has started seeing errors.
Write as much logging around your changes or features as is reasonable. When you were writing “if” statements in the code, was there a scenario that you thought to yourself “that should never happen”? Log a warning or throw an informative error, so you can confirm your assumptions.
Even when using all of these strategies, you will sometimes fail. Your code change may not take into account a corner case that no one knew existed. There will be a null field that you thought couldn’t possibly be null. Defects, regressions, missed requirements, and incorrect integrations will sometimes happen. This is a fact of developing in complex enterprise systems, especially ones that are considered legacy. Don’t let rollbacks get you down, I’ve seen some amazing developers rollback. Don’t let code patches leave your self-esteem in patches. Defects do not mean you are a defective developer. Acknowledge failures and fix the code. Take a deep breath and maybe even laugh about it. Then, get on with your day. Because ultimately, you’re taking on a difficult task and tacking some serious work that needs to be done.
Sail onward. Be that hero. Slay the dragon.