Refactoring Legacy Code: Static Dependencies
Refactoring using functional dependency injection
Legacy code is fun to work with since there's always an endless volume of interesting problems to solve. The challenges of working with legacy code often involve:
- Tight coupling
- No tests
- Difficulty extending
The last one is the most important because it's a crutch on developer productivity. The first step in moving forward and improvement is by adding tests to ensure that any new functionality doesn't break old functionality.
In this quick and dirty post, I'm going to illustrate one method of extracting external dependencies safely from tightly coupled code to be able to put the code under test. We'll start with this example code:
public sealed class Foo {
public void Bar() {
// 1000's of lines of code
Manager.StaticMethod1("Do something");
Manager.StaticMethod2("Do something else");
// More code
}
}
The code is a challenge to test if the static methods of Manager
cause side effects - such as writing to a database. The other challenge is that these are static methods, so we won't be able to inject Manager
into the function Bar
. We could change the implementation of Manager
to be an instanced object, but in some cases that would unravel other problems that might take away from the task at hand.
Refactoring legacy code requires a lot of care because without any tests, there is no feedback on whether changes will cause failures. In this particular case, the safest approach is to add a mocking library. Mocks can be useful for avoiding all the implementation details of the static methods and doesn't change the implementation in any way.
However, in some cases adding libraries aren't allowed due to regulatory reasons or corporate policies. A second option is to introduce test stubs or test. I prefer test stubs because it generally promotes more testable code.
To get this code under test, we can use functional dependency injection. In a functional approach, we inject functions into our method. With the above example, we can refactor Bar
to accept these functions as inputs and create a no-argument override of Bar
to inject these functions in. These changes won't affect how consumers use this, but will expose a version of Bar
where our test harness can inject controlled behaviour in. The testable version could look like this:
public sealed class Foo {
public void Bar(Action doSomething, Action doSomethingElse) {
// 1000's of lines of code
doSomething("Do something");
doSomethingElse("Do something else");
// More code
}
public void Bar() {
Bar(Manager.StaticMethod1, Manager.StaticMethod2);
}
}
The functional interface or delegate of Bar
will get pretty nasty, but the main goal is to get the code under test. After the tests are written, it should be safe to refactor Bar
with the confidence that Bar
will continue to do what it's meant to do.