Working with the Legacy Code
By Mikaël Hubert-Deschamps
In January 2024, I had the chance to co-present the monthly Techno Drinks at our offices in KuriosIT in Sherbrooke. The theme of the evening was techniques for working with legacy codeor legacy code. The presentation went through a number of improvements that can be made to this type of project, whether in terms of code syntax, structure or production methods. This blog post will provide an overview of the points raised during the conference.
Before I begin, I would like to acknowledge the excellent work of Michael C. Feathers in his book Working Effectively with Legacy Code published in 2004. This book provides a clear definition of what legacy code. It presents refactoring methods according to the desired objective, a multitude of situation scenarios where the author explains possible solutions step by step, and different approaches to adding unit tests. I recommend this book to anyone who wants to learn more about working with old code!
Setting the scene
As part of an ongoing project with one of our customers, I had to support several applications designed in C#, more specifically in WinForms .NET Framework 4.7.2. These applications have all been in use for several years, and some of them needed (and still need) support to add new features or fix existing bugs. I consider these applications to be legacy because in my case, it's much more about an application without documentation.
Whether through a readmeIn fact, we had no information available other than the source code, either through comments in the code, or through the customer's user experience. What's more, no source manager (i.e. GitHub) had been used during application development. So we had to sit down, pour ourselves a cup of coffee and... read the code!
After nine months of immersing myself in these applications, I straightened out the structure of the largest of the multiple applications we took over from the customer. I've also added several features and brought back to life some parts that were no longer supported. Here are all the changes made to the main application.
New functions
In this project, the aim was to deliver new functionalities while refactoring the application. In the early days of support, even adding and modifying users was complicated for the administrator: you had to connect directly to the database and modify table contents! So the first addition was a user management interface. Subsequently, we re-established several functionalities which, due to lack of maintenance, had been out of service for several years. All these additions (and more) enabled the customer to use his application exactly as he wanted, without needing our support for every operation.
Production launch process
To put the application into production, an executable file was previously produced and deposited on the company's file servers, along with all the libraries and other files required. This method brought a number of problems: it was impossible to put the application into production ourselves due to lack of access, the application was unavailable if the server was down, configuration files were visible and could be modified by the user, and so on.
In short, this is not good practice at all, especially when better options are available. So I used Wix to generate an installation file .msi and deploy the application directly to users' workstations. Gone are the random latency errors caused by the file server and the mysterious bugs of missing files or configurations. We're back in control of the application environment!
Unit testing
Remember, an application without tests is considered to be legacy ! So we added a few unit tests to validate the various service functionalities. These tests resolved several bugs that we hadn't found during functional testing. These tests therefore paid for themselves even before they were added to production.
Refactoring
In order to extend the life of the applications, I did a lot of refactoring. Initially, the structure was monolithic: a main form (WinForms, of course) containing all the logic, with several children exchanging information with the parent. The main class contained over two thousand lines, many of which were no longer in use. I had to remove all the dead code and clarify the variable names.
I also took the opportunity to migrate the main application to .NET Core 8. This enhancement alone brings a great deal of optimization and security to the application.
After these changes, even if the code is easier to read, we still have a monolith... So I created services injected into the main form to better handle the various objects in the application. For example, if an action modifies an employee's information, these changes will now go through the associated centralized service. No more duplicates!
Next, I undertook a massive clean-up of the code, rewriting segments that were clearly not optimal (see example below). Finally, I grouped all SQL queries under a single file responsible for communications with the database. It was during this stage that a large number of bugs, which were rendering the application unusable, were found and fixed.
Speaking of SQL queries, we've also integrated Dapper to link relational objects with those of our application domain. This addition has made it possible to standardize property naming and optimize transactions. Configuring this tool is very simple. The time you save using it and the security gains (such as code injection protection) are considerable!
Finally, we also updated all the external libraries we used. Some of them had serious security problems. It was therefore imperative to update them.
Here's an example of a code segment to be optimized:
Let's assume 'foo' is a logical value (boolean) and 'bar' a character string (string). (This example is actually 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 operator also works
break;
}
In this example, the program uses a switch case to determine 'bar' according to 'foo'. In reality, a switch case is used to choose between more than three cases and the input parameter is normally a type other than boolean. The method shown works, but is not optimal. A ternary operator is therefore more appropriate, since only two cases are possible.
Here's the optimized version replacing the previous block:
bar = foo ? "foo is true" : "foo is false";
Conclusion
Now we can breathe easy: the code is clear, everything we see is used, optimized and the classes are structured. In short, all these changes are individually trivial, but taken together, they form a formidable weapon against legacy code. In just a few months, we've gone from a fragile, monolithic application to a high-performance, stable and flexible work tool. The next developer who doesn't know the history of the application will be able to take control of the code more easily. They'll be able to read the documentation and tests, consult the various injected services and deploy a new version in the blink of an eye!
Curious about how to turn one of your applications into a high-performance, stable and flexible tool? I invite you to read the book by Michael C. Feathers and learn more about the subject we've covered together.