How to Refactor Duplicate Methods with Subtle Differences
Produce a cleaner codebase that’s faster to develop, easier to test, but still just as powerful
Hi Friends.
In last week’s newsletter, we explored a real-world example of how programming to an interface can make life easier when we need to make changes to an existing codebase. Requirements changes can come about unexpectedly. Writing code against abstractions of our components means we’re free to change specific implementations with others where required.
This week, we’ll see an example of how this concept can be used to make our code more reusable.
A Tale of Two Apps
Let’s imagine we inherit a project to work on. Its purpose is to read in data from user-specified sources, transform it, and relay the mapped data to another system. It consists of two complementary subsystems:
A Windows desktop app, complete with a UI that allows you to experiment with various data conversion options.
A command line ‘player’ that runs preconfigured transformations created using the desktop app.
The codebase consists of two projects in a Visual Studio solution, one corresponding to each of the previously mentioned systems. The mapping logic in the desktop app contains statements to write logging messages to the UI’s log window. Conceptually, it looks something like the following:
public void ReadAndTransformData()
{
LogWindow.Clear();
LogWindow.Text += "Reading in data...\r\n";
var data = ReadData();
LogWindow.Text += "Finished reading data. " +
"Starting transformation...\r\n";
TransformData(data);
LogWindow.Text += "Data transform complete!";
}
The core logic is duplicated in the command line version, but with a few modifications. For example, as there’s no dedicated logging window in console apps, LogWindow.Text
is replaced by Console.WriteLine
.
public void ReadAndTransformData()
{
Console.Clear();
Console.WriteLine("Reading in data...");
var data = ReadData();
Console.WriteLine("Finished reading data. " +
"Starting transformation...");
TransformData(data);
Console.WriteLine("Data transform complete!");
}
There isn’t too much logic in place to begin with, so having two nearly identical copies of the code is manageable. But more features are added over time. Both the size and complexity of the projects grow to a point where they’re difficult to maintain. Refactoring the projects to share code would result in a single codebase, and would have two benefits:
Changes in one app would carry through to the other, meaning less development time.
Both apps would share the same logic, meaning testing would be easier.
But a desktop app can’t log to the console, and a console app doesn’t have a logging window: how could we bridge the (subtle but important) differences?
Looking at the Bigger Picture
Let’s take a step back for a moment and consider what we’re doing. We have a task of reading in data and transforming it. Because it can take a while, we want to give feedback at key moments. In the context of running this process, it’s unimportant whether we have a logging window or if we need to write to the console: we want to let the user know what’s happening, but we shouldn’t concern ourselves (at this level) with how we do it. To realise this this abstraction, we can write an interface for a logger that we can use instead of the specifics we previously had in place.
public interface ILog
{
public void Clear();
public void Log(string message);
}
We can then refactor the core logic. For this to be available in both apps, we can move it into a shared library referenced by both projects.
public void ReadAndTransformData(ILog logger)
{
logger.Clear();
logger.Log("Reading in data...");
var data = ReadData();
logger.Log("Finished reading data. Starting transformation...");
TransformData(data);
logger.Log("Data transform complete!");
}
Now that our refactored process is logging to an interface, we can write classes to connect output messages to their appropriate destinations. For the desktop app, we might write something like the following:
public class LogWindowLogger : ILog
{
private readonly TextBox _logWindow;
public LogWindowLogger(TextBox logWindow)
{
_logWindow = logWindow;
}
public void Clear()
{
_logWindow.Clear();
}
public void Log(string message)
{
_logWindow.Text += $"{message}\r\n";
}
}
And the following for the console app:
public class ConsoleLogger : ILog
{
public void Clear()
{
Console.Clear();
}
public void Log(string message)
{
Console.WriteLine(message);
}
}
Summary
If you have repeated copies of similar functions but with subtle differences, identifying abstractions can help to refactor them into common shared code modules. When you focus on what you want your code to do, rather than how, you’ll be able to find common themes.
The specifics of how will be important. But they’re usually smaller in scope and can be provided in separate modules specifically built for their targeted environments. And when combined, you’ll have a cleaner codebase that’s faster to develop, easier to test, but still just as powerful.
Bonus Developer Tip
The usual shortcut for pasting text without formatting (Ctrl+Shift+V
) doesn’t work in some Microsoft Office apps, e.g. Word. As an alternative, you can press Shift+F10
to bring up the right click menu. Then you can paste without formatting by pressing T
.
These tips are exclusively for my Substack subscribers and readers, as a thank-you for being part of this journey. Some may be expanded into fuller articles in future, depending on their depth.
But you get to see them here, first.