In a perfect world, all the code you are responsible for maintaining would have been written by you according to the latest and best coding conventions.
In the real world, developers are often given the task of maintaining legacy code written by someone else. That’s not usually a fun task.
But it is one that you can handle with the right approach. In this article, I walk through three different strategies that can help you maintain legacy C# code more effectively: refactoring it, testing it, and (if all else fails) rewriting part of it without breaking backwards-compatibility.
What is Legacy Code?
First, let me explain what legacy code means. Wikipedia defines legacy code as code in an application that is no longer supported. Legacy code has also been defined as “profitable code that we are afraid to change.” This kind of code is likely to exist in old organizations as well as organizations that have to change rapidly.
Coming face-to-face with code nobody wants to touch (and in some cases, nobody understands) is almost certain for every developer. Therefore, it is important to understand how to deal with this instead of yielding to the urge to rewrite the code using the awesome new framework everyone talks about. (That approach will most likely lead to failure and unnecessary cost, as you would be ignoring all the hindsight from the legacy code that could have improved your new design.)
Challenge #1: Refactoring C# code
Legacy C# code may have to be refactored for a number of reasons, which could include making the code more readable, more modular or more testable. Refactoring can break bad design patterns in legacy code (instead of simply adding more code using the existing pattern).
Code may be refactored to eliminate the presence of multipurpose classes which violate the concept of separation of concerns. Having a class that performs one clearly defined role makes code easier to read and easier to test. An example of refactoring a multipurpose class can be found below.
[Before refactoring] using System; namespace HydroponicFarm { public class FarmController { public static int getPhReading() { Console.WriteLine("getting pH reading"); return 0; } public static int getTemperatureReading() { Console.WriteLine("getting temperature reading"); return 0; } public static int getHumidityReading() { Console.WriteLine("getting humidity reading"); return 0; } public static TuplegetSensorReadings() { int pH = getPhReading(); int temperature = getTemperatureReading(); int humidity = getHumidityReading(); return Tuple.Create(pH, temperature, humidity); } public static void increasePh(int increment) { Console.WriteLine("increasing pH by: " + increment); } public static void reducePh(int reduction) { Console.WriteLine("reducing pH by: " + reduction); } public static void adjustPh() { if (getPhReading() > 7) { increasePh(10); } else { reducePh(10); } } public static void openBlinds() { Console.WriteLine("opening blinds"); } public static void closeBlinds() { Console.WriteLine("closing blinds"); } public static void adjustTemperature() { if (getTemperatureReading() > 25) { closeBlinds(); } else { openBlinds(); } } public static void pushDataToServer() { Tuple readings = getSensorReadings(); } public static void checkServerForSettings() { Console.WriteLine("checking server for new settings"); } } }
Three functions were identified in the code:
- Sensors
- Control System
- Server Communication
[After refactoring] using System; namespace HydroponicFarm { public class Sensors { public static int getPhReading() { Console.WriteLine("getting pH reading"); return 0; } public static int getTemperatureReading() { Console.WriteLine("getting temperature reading"); return 0; } public static int getHumidityReading() { Console.WriteLine("getting humidity reading"); return 0; } public static TuplegetSensorReadings() { int pH = getPhReading(); int temperature = getTemperatureReading(); int humidity = getHumidityReading(); return Tuple.Create(pH, temperature, humidity); } } public class ControlSystem { public static void increasePh(int increment) { Console.WriteLine("increasing pH by: " + increment); } public static void reducePh(int reduction) { Console.WriteLine("reducing pH by: " + reduction); } public static void adjustPh() { if (Sensors.getPhReading() > 7) { increasePh(10); } else { reducePh(10); } } public static void openBlinds() { Console.WriteLine("opening blinds"); } public static void closeBlinds() { Console.WriteLine("closing blinds"); } public static void adjustTemperature() { if (Sensors.getTemperatureReading() > 25) { closeBlinds(); } else { openBlinds(); } } } public class ServerCommunication { public static void pushDataToServer() { Tuple readings = Sensors.getSensorReadings(); } public static void checkServerForSettings() { Console.WriteLine("checking server for new settings"); } } }
Challenge #2: Testing legacy C# code
Another problem with legacy code is testing. This may be difficult because the code may not be structured in a way that makes dependency injection simple (or possible). In addition, the structure of the code does not make the logic of the code modular. Both of these issues would require some refactoring. However, there are a number of techniques you should consider while refactoring.
To maintain the functionality of your code while refactoring (which is hopefully what you’re trying to do), we can apply the Golden Master.
Referring to the refactoring example above, the output to some unique inputs can be saved before making changes to the code. The output of this code will be used as the Golden Master, and the input-output combination should match after refactoring the code.
This approach will quickly become a problem if your inputs can create large combinations of outputs. It might be impractical to account for all the possible combinations of inputs and outputs — However, you could sample the outputs. An example of sampling is developing an algorithm where you can test the boundary conditions, a few conditions in between, and be sure the algorithm will work for all other combinations. This approach will greatly reduce the complexity of creating your Golden Master.
Challenge #3: Changing Legacy Code in C#
Lastly, you may want to make changes like adding new features to your legacy code and/or refactoring some things while maintaining backwards-compatibility because a lot of applications may depend on your code. This presents a challenge because adding a new feature may require you to write more legacy code, which is not ideal. Additionally, you may want to change some interface methods because new use cases have come up that require a more flexible interface. This requires some thought because changing the interface may break backwards-compatibility — which might mean that a lot of applications that depend on that code will no longer work.
The adapter pattern is a good way of changing the behavior of a class in your legacy code without having to write more legacy code. This allows you to write your new features separately and later connect them to the legacy code. And it eliminates the need to write more legacy code in order to extend the functions of your legacy code. The code below shows an example of the adapter pattern in use.
using System; namespace LegacyCode { class MainApp { // Main Application static void Main() { // Create adapter and place a LegacyAction LegacyClass LegacyClass = new Adapter(); LegacyClass.LegacyAction(); } } // Legacy Code class LegacyClass { public virtual void LegacyAction() { Console.WriteLine("LegacyAction()"); } } class Adapter : LegacyClass { private NewBehavior _newBehavior = new NewBehavior(); public override void LegacyAction() { // Replace legacy behaviour with new behavior _NewBehavior.NewAction(); } } // New code to replace legacy behavior class NewBehavior { public void NewAction() { Console.WriteLine("NewAction()"); } } }
Source: https://www.dofactory.com/net/adapter-design-pattern