Work with “Legacy Code”
By Mikaël Hubert-Deschamps – March 2024
In January 2024, I had the opportunity to co-present the monthly Techno Drinks event at our KuriosIT offices in Sherbrooke. The theme of the evening centered around techniques for working with legacy code. During the presentation, we explored several improvements that can be applied to such projects, including code syntax, structure, and deployment methods. This blog article will provide an overview of the key points discussed during the conference.
Before diving in, I’d like to acknowledge the excellent work of Michael C. Feathers for his book Working Effectively with Legacy Code, published in 2004. Feathers’ book offers a clear definition of what constitutes legacy code. It presents various refactoring methods tailored to specific goals, along with numerous real-world scenarios where the author explains step-by-step solutions and different approaches for adding unit tests. I highly recommend reading this book to anyone interested in learning more about effective strategies for working with old rotten code!
Context
As part of an ongoing project with one of our clients, I had to support several applications designed in C#, specifically using WinForms .NET Framework 4.7.2. These applications have been in use for several years, and some of them still require support for adding new features or fixing existing bugs. I consider these applications as legacy because, in my case, it relates much more to an application without documentation.
Whether through a readme, code comments, or the client’s usage experience, we truly had no information available other than the source code. Additionally, no version control system (e.g. GitHub) was used during the development of these applications. So, we had to sit down, grab a cup of coffee, and… read the code!
After nine months of diving into these applications, I restructured the most critical of the multiple applications we took over from the client. I also added several features and revived parts that were no longer supported. Here are all the changes made to the main application.
New Features
For this project, the goal was to deliver new features while refactoring the application. At the very beginning of our involvement, even adding and modifying users was complicated for the administrator: they had to connect directly to the database and modify table contents! The first addition was a user management interface. Subsequently, we revived several features that had been out of service for years due to lack of maintenance. All these additions (and more) allowed the client to use their application exactly as they wanted, without requiring our support for each operation.
Deployment Process
For deploying the application, an executable file used to be generated and placed on the company’s file servers along with all the required libraries and other files. However, this method brought several issues: inability to deploy it ourselves due to lack of access, application unavailability if the server was down, and configuration files visible and modifiable by users.
In short, this approach was far from best practice, especially when better options were available. So, I turned to Wix to generate an .msi installation file, allowing us to deploy the application directly to users’ workstations. Say goodbye to random latency errors caused by file servers and mysterious bugs related to missing files or configurations. We’ve regained control over the application environment!
Unit Testing
Let’s remember, an application without tests is considered legacy! Therefore, we added some unit tests that validate the various features of the services. These tests helped uncover several bugs that we hadn’t found during functional testing. As a result, these tests were profitable even before being added to production.
Refactoring
To extend the lifespan of applications, I’ve undertaken significant refactoring. Initially, the structure was monolithic: a primary form (WinForms, of course) containing all the logic, with several child forms exchanging information with the parent. The main class had over two thousand lines, many of which were no longer in use. I had to remove all the dead code and clarify variable names.
Additionally, I took the opportunity to migrate the main application to .NET Core 8. This improvement alone brings significant optimizations and security enhancements to the application.
Despite these changes, we still had a monolith. So, I created injected services within the main form to better handle various application objects. For example, if an action modifies employee information, these changes now pass through the centralized associated service. No more duplications!
Next, I embarked on a massive code cleanup by rewriting segments that were clearly suboptimal (see the example below). Finally, I consolidated all SQL queries into a single file responsible for communicating with the database. During this step, a significant number of bugs that rendered the application unusable were discovered and resolved.
Speaking of SQL queries, we also integrated Dapper to bridge the gap between relational objects and our application domain. This addition standardized property designations and optimized transactions. Configuring this tool is straightforward, and the time saved by using it, along with the security benefits (such as protection against code injection), are substantial!
Finally, we also updated all the external libraries used. Some of them had serious security issues, so updating them was imperative.
Here is an example of code segment to be optimized:
Let’s consider ‘foo’ as a boolean value and ‘bar’ as a string. (This example is directly taken from the original code.)
switch(foo)
{
case false:
bar = "foo is false"; // no need for a switch case
break;
case true:
bar = "foo is true"; // a ternary would have done the job
break;
}
In this example, the program uses a switch case to determine `bar` based on `foo`. In reality, a switch case is intended to condition more than two cases and the input parameter is of a type other than boolean. The method shown works, but it’s not optimal. A ternary operator is more suitable since only two cases are possible.
Here is the optimized version that can replace the previous block:
bar = foo ? "foo est vrai" : "foo est faux";
Conclusion
Now we can breathe a sigh of relief: the code is clear, everything we see is used, optimized, and the classes are well-structured. In short, all these changes may seem individually trivial, but when combined, they form a formidable weapon against legacy code. In just a few months, we’ve transitioned from a fragile, monolithic application to a high-performing, stable, and flexible tool. The next developer, even without knowledge of the application’s history, will be able to take control of the code more easily. They can read the documentation, review the tests, consult the various injected services, and deploy a new version with a snap of their fingers.
Still curious to make your software efficient, stable and flexible? I invite you to read Michael C. Feathers book and keep learning even more on the subject we just overviewed together.