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.