Writing code according to the latest guidelines and following current technological trends is not enough to prevent the emergence of legacy code in a project. Code written according to documentation is often expanded with additional features over many years. Without regular refactoring, which means improving its readability and quality over time, the code ages.
What does legacy code mean? How can you effectively work with legacy code?
Let’s decode the definition of “legacy code”
What exactly is legacy code? Translating the term, we can say that it is code that we take over for “processing” from another company, from another developer or from another team of developers. Some simply describe it as old code without documentation, code written in a different, older technology, or using outdated design methodologies. Recently, the definition popularized by Michael Feathers in his book “Working Effectively with Legacy Code” has gained popularity. According to him, it is code that is not covered by tests or has poorly written tests.
That’s the theory, but in practice, we recognize legacy code when we have to delve into it and analyze it to understand how it works. Even code with tests, if it is outdated, can be referred to as legacy code by programmers.
Every code ages
Legacy code often functions normally, is deployed in production, and delivers value—it allows users to benefit from the application’s functionality based on that code, enabling the company to generate revenue. However, this code usually requires some modification, such as adding new features or improving performance. After all, why tinker with something that fulfills its role?
Although there are very old codes that perform well for many years without requiring modifications, these are rare cases. Code needs constant work, constantly patching new security holes or updating libraries. Otherwise, after some time, attempts to install new dependencies will fail.
The main reasons for introducing changes in software are as follows:
- Adding new features
- Fixing bugs
- Enhancing the project
- Optimizing resource utilization
How to approach working with legacy code?
Less experienced programmers are often afraid that when they make changes to legacy code, something in it may stop working. They would prefer to work on a project from scratch, without imposed limitations, and fully showcase their creativity. However, they quickly realize that even in such cases, they need to impose certain limitations on themselves. Legacy code immediately defines the framework of operation and also requires creativity, but in a slightly different way.
It is easier to manage legacy code that already has tests because they protect programmers from breaking existing functionalities.
Where to start when we need to refactor outdated code or add new features to it? Let’s follow a proven algorithm.
As many tests as possible
First, we need to protect ourselves from accidental changes to the code in order to preserve its existing functionality. The simplest way to do this is by conducting a large number of automated tests. Whether they are unit tests, integration tests, or end-to-end tests depends on the size of the smallest isolated portion of legacy code that is essential for working on a particular functionality.
Unit tests will be enoigh if we can isolate a legacy function. If we separate a module that is related to other modules, then let’s use integration tests.
The problem arises when we need to make changes to legacy code and discover “dragons” lurking within it—unintelligible fragments that are also interconnected. In such cases, conducting end-to-end tests becomes necessary.
Even if the code doesn’t have tests and documentation, the functional behavior is already encoded in the code, and our task is to extract it. The simplest way to achieve this is by using smoke tests (also known as non-ambiguous tests). We treat the code as a black box – we don’t examine it and don’t need to know how it works. Instead, we provide input data, function arguments, and record the output data. We place the output data string in a unit test.
For integration tests or end-to-end tests, we need to take snapshots of modules or the entities that influence those modules from the module we are testing, or take snapshots of the entire application. Although these tests may run slower, we generate as many of them as possible to achieve 100% code coverage.
The extent of testing depends on the significance of a particular code portion. For example, if it’s a payment gateway that affects financial income, its tests should be dense. For simpler, less critical functionalities, we can feel confident with lower test coverage.
The next step could involve using a tool for generating mutation tests. Legacy code undergoes mutations – for example, if the code contains the number 30, the tool generates new versions of that code with -30, with zero, with 1000, with -1000, with null, with a string, and so on. It adds or removes if statements in various code locations or generates boundary conditions in for loops to mutate the code. Then the tool runs tests on the mutated code. If the tests still pass on the mutated code, the “mutant” survives. If the tests fail, the “mutant” dies. Our task is to kill the mutants. This ensures that not only code lines or specific instructions are covered, but also that the code checks significant portions that may fail when working on it.
After conducting numerous smoke tests, we still don’t know how the code works, but we know it will work – if the tests pass, the code behavior remains unchanged.
Time for modifications
Now we boldly move on to the creative stage – we enter the code and introduce modifications, having the feeling that tests will protect us against unintentional code corruption – they will catch changes in fragments that we should not interfere with.
We can conduct research and development by applying spikes, which will verify whether the functionality we want to deliver can be achieved by modifying a specific part of the code in a certain way.
Two steps forward, one step back
This is where the mikado method technique (a game of fiddles in Polish) comes to the rescue. How does it work? We enter the code and attempt to deliver the desired functionality by writing new lines of code and making modifications. When we encounter various compilation and dependency errors, some shortcomings, we note them on a piece of paper as steps. We undo the changes and try to incorporate everything we wrote down on the piece of paper. We alternate forward and backward after encountering problems. This builds a dependency graph – the things we need to change in the code by refactoring to be able to deliver functionality. Once the false steps are eliminated, all that is left are the steps that lead us straight to the introduction of the functionality.
In order to deliver the new functionality, tests that test the functionality will also be necessary.
That’s the essence of test-driven development (TDD) is all about. We start by writing tests to enforce the implementation. This allows us to clearly define our action plan. As we progress and deliver the functionality, the failing tests will eventually turn green. In the end, we have a set of smoke tests that protected the old functionality, as well as new tests that validate the new functionality.
However, there can be a challenge when the new functionality contradicts the previous behavior of the program. In such cases, some of the smoke tests that defended the old behavior may start to fail. For example, when making changes to an accounting program, we may encounter issues with variable VAT rates. In such situations, consulting with the product owner, who is knowledgeable in accounting, can help us decide which tests can be replaced with new ones and which tests need to be retained. This ensures that the necessary adjustments are made to accommodate the changes while still maintaining the desired behavior of the software.
Refactoring and Optimization
Once we have successfully delivered the desired functionality and all tests are passing, we proceed with the next standard step in test-driven development, which is refactoring.
Refactoring is the process of improving a project without changing its behavior. The underlying idea behind refactoring is that we can make the program easier to maintain without altering its external functionality, as long as we write tests that guarantee the preservation of the current behavior. To verify this assumption throughout the process, we proceed in small steps.
Programmers have been cleaning up code for years, but only in recent years has refactoring taken off. Refactoring differs from general code cleanup in that it involves not only low-risk actions like code reformatting, or invasive and risky techniques like rewriting significant portions of it. Specifically, we introduce a series of small, structured changes supported by tests to make the code easier to modify. From this point of view, the key thing about refactoring is that when we refactor, we shouldn’t make any functional changes (although the behavior may change in some way, since the structural changes we make can affect performance – either positively or negatively).
Optimization is similar to refactoring, but when we do it, we have something different in mind. In both refactoring and optimization, we say, “When we make changes, we intend to keep exactly the same functionality, but we will change something else instead.” In refactoring, that “something else” is the program’s structure — we want it to be easier to maintain. In optimization, on the other hand, that “something else” is some resource used by the program, typically time or memory.
Adding new features, refactoring, and optimization all leave the existing functionality unchanged. If we take a closer look at bug fixing, we’ll notice that it actually changes the functionality, but these changes are often very small compared to the scope of the existing functionality that remains unchanged.
Dependency is one of the most critical issues when it comes to software development. Most of the work on legacy code involves removing dependencies, making it easier to introduce changes.
Be careful with refactoring
Is it safe to perform refactoring without tests, such as simplifying a parameter type or extracting an interface? When we remove dependencies, we can often write tests to make more invasive changes safer. The trick is to perform these initial refactorings very carefully.
Being careful is the right thing to do if we are likely to cause bugs, but sometimes when we remove dependencies to cover the code, things don’t go well. However, sometimes things don’t go well when we remove dependencies to achieve code coverage. We might add parameters to methods that are not strictly necessary in production code or break classes in peculiar ways just to accommodate tests in specific places. When we do this, the result may be a partial deterioration of the appearance of the code at this point. If we were less cautious, we could fix it right away. We can choose to do it this way, but it depends on the risks involved. When errors matter, and they usually do, caution pays off.