♻️

(EN) Reflections on Working Effectively with Legacy Code

If a project reaches this lifespan, there’s a high chance that a significant portion of it—if not the majority—will be considered legacy code. In fact, even younger projects can already contain legacy code. That’s why I believe it’s essential for every developer to know how to handle these situations.
In my quest to improve in this area, I read Working Effectively with Legacy Code by Michael C. Feathers. In it, I found various techniques and patterns for safely modifying legacy code. However, what impacted me the most were the reflections I share in this post. The content of the book resonated deeply with the systems I've been working on, and this combination certainly contributed to my growth as a developer.

What is Legacy Code?

A software project is not like a building that deteriorates over time. If the software was implemented in a scalable and bug-free way ten years ago and hasn’t been altered since, it will continue to work as expected¹. The real problem arises when the code is modified inappropriately.
The introduction of poorly designed code is almost inevitable in the software lifecycle. The chances of this happening increase significantly when the code we are altering lacks documentation, has a confusing architecture, inappropriate naming, and, most critically, lacks automated tests.
Therefore, a practical definition of legacy code could be: code that is difficult to understand and modify without introducing new bugs.
¹ Except in cases of hardware failure or security vulnerabilities unknown at the time of its creation.

Understand Before You Change

In daily work, as new demands arise, it’s essential to carefully analyze how to implement them in the system. This analysis must strike a balance: if it’s too detailed, we delay delivering value to the user; if it’s too shallow, we risk overlooking important details and compromising the quality or feasibility of the delivery.
Developers with greater knowledge of the project can be more agile and accurate in these analyses. However, when dealing with legacy code, we are always walking on delicate ground. Relying solely on experience can lead us to miss obscure, non-intuitive behaviors that can undermine our plans. Sometimes, we can spot these issues during the development cycle and adjust course before delivery. But at other times, we only realize the problem when the user reports that something no longer works — and this happens because legacy code often lacks automated tests to prevent problematic code from being deployed.
To avoid these issues, it’s crucial to understand the current behavior of the code. If developers don’t have enough confidence in the analysis, the first step should be to take time to understand how the system's components are connected. Michael Feathers presents several strategies for conducting this study. One of the most interesting and powerful is the technique of transitional refactorings, which are temporary changes to the code, made only to gain clarity about a specific area of the system without committing to keeping them.
Since adopting this practice, I make sure to complete an analysis only when I’m confident that I’ve understood the system well enough to make changes. This allows my team to have clarity about what they are committing to and to set more realistic deadlines.

Work with Reliable Feedback

Modifying code without test coverage is risky; modifying untested legacy code is a recipe for disaster. When you change something you barely understand, how can you be sure the impact will be only what you intend?
Manual testing is helpful but prone to human error, making it easy to overlook parts of the system affected by changes. Automated tests, on the other hand, consistently verify the same behaviors and help identify potential impacts. When done well, they act as a safety net, catching unintended side effects and ensuring that critical areas of the system are not accidentally altered. Automated tests also guide code design by serving as the first “clients” of new implementations, providing a preview of how the system will use the changes.
No matter how confident I am in a change, I only make it if the code is covered by tests. If there is no coverage, I perform the minimal refactorings needed to enable testing and use effect sketches to map out the necessary tests. These sketches, also described in Feathers' book, are simple flowcharts that help understand which parts of the system are affected by specific changes. This way, I build a safety net that prevents the introduction of bugs and gradually improves the project.

Break Best Practice Rules If Necessary

In legacy code projects, we face numerous challenges. Often, the complexity and lack of clarity make it difficult to understand various parts of the system. In this context, development best practices need to be reconsidered and adapted to reality.
Imagine you are developing a new feature. After completing most of the work, you realize you need to add behavior to an existing class. You find a method that seems perfect for this, but it’s already overloaded and confusing. What should you do? Modify that method, risking making it worse, or create a new method that can be called when needed?
There are two paths when modifying a method: improvement or degradation. Improvement involves refactoring the code and creating automated tests, which takes more time but reduces technical debt. Degradation means simply adding the new behavior quickly and carelessly, sacrificing clarity and future maintainability.
In the long run, improvement is always the most advantageous route. However, when refactoring is not essential for the task at hand, insisting on it can delay the project, create pressure, and increase the likelihood of poor decisions. Worse, introducing changes to methods unrelated to the primary functionality increases the risk of creating bugs that go unnoticed, especially if there are no automated tests to cover those changes. In such cases, creating a new method, separate and focused on the new functionality, may be a safer and more efficient solution, avoiding bugs and maintaining design integrity.
This approach is not limited to adding new functionality. Sometimes, it’s worth breaking encapsulation rules, for example, when it’s impossible to test a class easily without accessing private members. Similarly, it may be necessary to introduce production code solely to enable testing. When time is short, simple and tested code is preferable to complex code with poorly applied “best practices.”

Respect the Code

Early in my career, whenever I encountered legacy code, I tended to refactor it almost immediately. I would read the code, interpret its behavior, and start altering it to something “better.” However, with time and experience, I realized how irresponsible this approach was.
Even if a project is full of legacy code, if it’s still in use, it’s because it continues to provide value to people. Changing that code without first understanding the system’s domain, who its users are, and how each part connects can lead to poor design decisions. Moreover, the lack of deep understanding significantly increases the risk of introducing new bugs.
Another common thought when dealing with legacy code is, “Why not rewrite everything from scratch?” Initially, this idea seems tempting, but completely rewriting a system requires a massive effort from the development team, often sidelining other priorities like new features. This can cause the product to stagnate. Moreover, the rewrite will never be a simple copy of the original system (because if it were, what would be the point?). When attempting to recreate the system, it's almost certain that you will forget some obscure rule and introduce bugs that were already fixed in the legacy code. As a result, the project will not only stop evolving, but it will likely get worse. (I grasped this concept well after reading a post from Joel on Software.)
After understanding these dynamics better, I began to respect the knowledge embedded in the code, even when it follows a “spaghetti architecture.” Improving the code is crucial to ensure the system’s longevity, but this must be done cautiously and with respect for what has already been built.