Making Friends with Legacy Code
Legacy Code is probably every software engineer’s worst nightmare. It’s so awful that every time we run into it, all I can think is “wtf.” Legacy code isn’t just hard to maintain, it also just seriously pisses us off! Because legacy code is so common, it must mean it's something that occurs very easily. Therefore, besides thinking about how to avoid legacy code, learning to come to terms with it (or maybe even making friends with it?), is important as well.
In this post, I want to share some thoughts on how to deal with legacy code when you run into it.
What is Legacy Code?
Before we talk about legacy code, we should define it — here’s Wikipedia’s definition:
Legacy code is source code that relates to a no-longer supported or manufactured operating system or other computer technology.
Why is Legacy Code a Problem?
The problem with legacy code is that part of the code isn’t being used anymore. Another problem may be that its design has fallen behind as the use case evolves. This type of code is frustrating for engineers for two reasons: we don't know when the code will be used and why it was written this way. Since it’s hard to tell which part of the code is being used and when it might be used in the future, we naturally refrain from changing it. Worse still, because we aren’t sure why the code was originally written that way, our new code might be overly complicated in order to accommodate the existing legacy code. What I’ve just described is a vicious cycle — the code that was written on top of the original legacy code has now become part of the problem.
Though I’ve provided Wikipedia’s definition of legacy code, I believe there’s a more accurate definition. According to Working Effectively with Legacy Code:
Legacy code is code without unit tests.
In other words, even if your code was written yesterday, if it doesn’t have unit tests, it’s legacy code.
Why is that? If the code was written with unit tests, it’s relatively easy to determine which sections of the code are still being used or to change its behavior to fit current use cases. (With that said, you have to start off with good unit tests…) On the other hand, if the code doesn’t have unit tests, it immediately becomes code that no one dares to touch. In effect, this would be the same as legacy code.
Why Does Legacy Code Exist?
Before we tackle the issue of legacy code, it’s important to understand why legacy code exists in the first place. Once we do, we can address the root of the problem and avoid writing legacy code in the future. Even though we’re always rather annoyed (more like swearing our heads off…) when we use
git blame, we know that the cause of this legacy code isn’t as simple as inexperience or carelessness.
Most of the time, legacy code is produced as a trade-off for development speed.
In the world of software development, feature requests are diverse and ever changing — well, sometimes, it’s the bad planning that causes constant shifts in direction...but you know what I mean. In fact, the ability to ship features quickly is one of the most foundational factors of successful teams. However, rapid development often results in a disparity between code and real-world business logic. After a few rounds of quick changes, most developers will be inclined to leave some extra leeway in their code.
The cost of more flexibility, however, is increased complexity. According to Murphy’s law, most of the flexibility we leave in our code won’t be used, since new features will evolve in ways we can’t predict!
Fixing the problem of legacy code is extremely difficult. Even just facing the problem head-on isn’t easy — nobody wants to touch shitty code. However, sometimes we don’t have a choice…as pissed off and annoyed as we might be, we must be able to approach the problem with a calm and positive attitude.
I know this isn’t directly related to programming, but then again, the reasons why legacy code exists are usually not directly related to programming either. The most common cause of legacy code is development speed (i.e. not leaving enough time for refactoring). What happens is that inexperienced team may not be able to design code that can keep up with product changes. For example, if the PM is under time pressure and failed to include additional time for refactoring, developers may end up writing legacy code unwillingly. Keep in mind, different people on the same team will inevitably have different priorities and responsibilities. With that said, everyone on the team should be aligned around the same goal.
In other words, any company must be able to produce products at a profitable speed. If products cannot be developed efficiently and effectively, even the most perfect code is useless.
Finding the Root of the Problem
We’re finally going to talk about how we can fix legacy code! Firstly, I think it’s important to figure out which abstraction layer the problem lies in. Most problems that seem complex usually have a clear root cause.
Using a web app as an example, when we run into situations where we think it’s painful to change the code, we might be dealing with problems from one of these abstraction layers:
Application layer One of the methods in a class wasn’t written well. The interfaces between some classes and modules were not designed well. Persistence (database) layer Schema isn’t normalized or is lacking corresponding constraints, which causes complications when writing queries. Schema design is outdated: for example, when the schema was designed for one-to-many relationships, but in reality, all use cases are one-to-one. Architecture layer The app is becoming too complex, so single page app architecture may be more fitting than server render. In certain situations, event stream or async call may be more suitable. Product layer End users may be using the product differently from how it’s being developed. When the two are not aligned, we may get some odd special cases.
I’ve only listed a few examples above — what you can see is that there may be issues in different abstraction layers. These issues, however, could be closely linked together. For example, if the database schema wasn’t designed properly, it would be very difficult to write well-structured code in the application layer. More often than not, problems can be found in multiple abstraction layers (e.g. there are problems with the schema as well as the code.)
No matter what the situation is, finding which abstraction layer the problem lies in is first step to fixing the problem.
What Do I Do With Legacy Code?
After you find the problem, the next step is to try to fix it! There really isn’t a cookie-cutter approach to fixing legacy code; they really do vary from case to case. Even so, there are a few methods and approaches we can refer to.
Fixing it as Features Evolve
This is a pretty common conversation when developers are trying to fix legacy code or just code in general:
“Can you still use it?” “Well yes, but…” (interrupted) “If you can still use it, why are we changing it?”
Fair enough — why would you spend time changing something that already works? From a product development point of view, changing code simply for the sake of better structure doesn’t make sense. After all, changing code structure doesn’t directly impact user experience! Besides, in most cases, features will continue to evolve anyway. For argument’s sake, let’s imagine a rather extreme case. Let’s say we decided to spend three weeks changing the code structure. It’s highly likely that by the time we’re done with all the refactoring, the feature was removed. That’s essentially three weeks of wasted time.
From another perspective, if we can find a good timing to change features and fix legacy code while we’re at it, we can kill two birds with one stone — the legacy problem would be solved without compromising product development pace!
Here’s a good example: let’s say a large chunk of our front-end functions contain legacy code. It doesn’t seem feasible to rewrite our entire existing UI just to deal with the legacy code. However, if we had to make large-scale changes to the UI anyway, we would naturally have to deal with the legacy code first. If we do not deal with the complicated legacy code first, there is no way we would be able to make big changes to the UI. When the new function is completed, we would have fixed the legacy code and made the code easier to maintain.
Prioritize High Leverage Issues
With most legacy code, there is often more than one place that needs to be changed. When you run across legacy code, you can analyze which parts of the code can be dealt with and choose the one that is the most cost-effective to fix. To figure out the cost, simply look at the amount of time and human resources it would take to do the refactoring/rewriting. However, figuring out which changes would be more cost-effective is a little more complex. Here are a couple of ways we can look at our “return”:
The most common way to look at “return” is through code maintainability. Legacy code is extremely difficult to deal with. As a result, whenever someone needs to build a new feature on top of the legacy code, he or she would have to spend extra time on the task. Worse yet, due to the complexity of legacy code, it may be close to impossible to estimate how long it would take to change the feature. Fixing these types of problems can speed up the whole development process. If you already know that a certain feature will be altered frequently in the future, it would be very cost-effective to fix the legacy problems now. On the contrary, if the feature will most likely not be changed in the future, even if there are legacy issues, they can be left alone for now.
Legacy code often comes hand-in-hand with performance issues that cannot be fine tuned. In order to identify performance issues, we’ll have to conduct a detailed profiling, find the problems within the code, and then proceed onto solving it, which may prove difficult if the code is messy and complex. If these problems can be resolved, the execution speed would increase drastically and make it easier to identify future performance issues. It is important to know how regularly this part will actually be used. If a specific page is used by a lot of users on a daily basis, it would be worth it to fix the legacy issues to enhance performance.
3. Development speed (build time, compile time)
Problems related to development speed are often neglected. In general, development speed refers to the time that it takes to build code, run unit tests, etc. Legacy issues may slow down this process, leading to horrible consequences. For example, if the database schema wasn’t designed properly, when you run unit tests, you may be forced to use truncation instead of transaction to remove testing data, resulting in slightly slower test cases. When every team member runs thousands of test cases each day, this time quickly adds up. Here’s a helpful equation for estimating just how astronomical the cost (in time) may be:
Extra time it takes to run each test case x unit test runs (multiple times for each test case) x number of team member
As you can imagine...the cost is truly enormous.
Worse yet, if this causes us to skip slow-performing unit tests, the debugging process will take even longer and code quality will suffer — it’s a vicious cycle.
In these situations, development speed is an important factor in determining whether or not we want to spend time on legacy code. If fixing the legacy code results in significantly increased development speed, it would be a very cost-effective choice.
How to Avoid Legacy Code
So, how do we avoid writing legacy code to begin with? Well, it’s definitely easier said than done. Here’s some advice on how to avoid legacy code:
You read that right — the culture of a team is directly related to the quality of code it produces. I believe an intrinsic care for code quality must be at the core of every software development team. Everyone makes mistakes; however, developers must be committed to their own code enough for future maintenance to be meaningful. If developers are sloppy, no matter how much maintenance has been done, the code will become messy again under his/her hands.
Additionally, we must face legacy code with a positive attitude. When developers run into bad codes, the knee-jerk reaction is to immediately
git blame and think “wtf.” Though understandable, this negative attitude can be harmful. Coding is a continuous learning process. No matter how experienced we are, sooner or later, we will run into issues we’re not familiar with or design badly-structured code. Most of the time, good code results from endless revisions, not perfect initial writing. A positive attitude will allow a team to grow and learn from its mistakes.
Don’t forget, we’re trying to fix legacy code, not legacy people!
YANGNI: You ain’t gonna need it
It’s difficult to design good code structure under rapid changes and immense time pressure. We’re always thinking about ways to maximize flexibility to account for possible changes. If we’re honest, products and user needs rarely ever follow our predictions. The flexibility we built into the code most likely only serves as an unnecessary complication in our code.
It is more effective to clearly identify the current needs, leave out any extra leeway, and only add it if we need it.
This approach might sound lazy and inefficient, but in reality, it will save us a lot of unnecessary time going back and forth between features we may or may not need. Since the code would be simpler, changing it would be a lot more straightforward. The central idea is that anything that is not necessary should not be included in the code. Along the same line, if a certain function has been removed, the corresponding code should be removed as well (don’t worry about losing the code — git will remember it for you). Additionally, if the business logic has changed, we should adjust the code as soon as possible, rather than using special cases to deal with it.
I must say, automatic testing really is the foundation of everything that we do. Everything that I’ve mentioned so far directly relates to refactoring code. If we didn’t have unit tests, there’s really not much we can do. Besides, when we’re writing tests, we’re forced to rethink the structure of our code — this will ensure that our code won’t be too off.
Legacy code is a common pain point for all developers. I believe all developers can learn from fixing legacy issues. The most important part of dealing with legacy code lies in the communication process. Telling someone “Hey, your code smells!” isn’t easy. However, it’s important to approach this kind of conversation with a positive attitude. After all, we don’t know if there was an extenuating circumstance that led to the legacy code. Don’t forget, we’re not immune to these circumstances. We may find ourselves in undesirable situations that force us to write legacy code unintentionally! In the process of dealing with legacy code, we should continuously remind ourselves, every mistake is an educational opportunity in disguise!
We should also always be aware of why we’re fixing legacy code — fixing it because we’re annoyed or obsessed about code quality are not good enough reasons. There must be enough practical reasons for us to fix legacy code. As developers, it is our job to evaluate the trade-offs between possible solutions and determine the best solution!
This post was translated to English by Codementor’s content team. Here’s the original Chinese post by Yang-Hsing, Codementor's full-stack developer.