Adding Dependency Injection to an Existing Solution

by John Miller | January 30, 2025

Modernizing legacy code can feel like renovating an old house – you know the foundation is solid, but there are improvements to be made. This article explores how to improve the maintainability and testability of existing code by introducing dependency injection.

Making Big Changes to a Legacy Code Base

Sometimes when working with legacy code, the code is not as testable as you may like. Making the code more testable requires, in part, to loosen tight dependencies in order to reduce the scope of the code under test.

What Is Legacy Code?

According to some, code becomes legacy once it's pushed to main and released to users. The heuristic, when you write code, You and God know how it works; in a month, only God knows, is a good way to think about legacy code. Another more precise definition is legacy code lacks sufficient tests. One benefit of test automation is that the tests document the expected behavior of the code under test.

Some Reasons Why Code Is Not Testable

There are many factors that make code testable. Some of the reasons code may not be testable are listed below:

  • Tight coupling: Dependencies are hard-coded, making it difficult to isolate the unit of code under test.
  • Lack of interfaces: Without interfaces, it's challenging to mock dependencies.
  • Global state: Use of global variables or singletons can lead to unpredictable behavior in tests.
  • Complex logic: Methods with high cyclomatic complexity are harder to test.
  • Side effects: Code that performs I/O operations, such as reading from or writing to a database or file system, can be difficult to test.
  • No dependency injection (DI): Without DI, it's hard to replace real dependencies with mocks or stubs.
  • Hidden dependencies: Dependencies that are not explicitly passed to the class or method make it hard to control the test environment.
  • Static methods: Static methods can't be easily mocked or stubbed.
  • Large methods: Methods that do too much are harder to test in isolation.
  • Lack of modularity: Code that is not broken down into smaller, reusable components is harder to test.

For the purposes of this post I'm focusing on tight coupling, the lack of interfaces, and no dependency injection.

Addressing Tight Coupling with Interfaces and Dependency Injection

Tight coupling is when components in a system are highly dependent on each other. Changes in one component often require changes in another, making the system harder to maintain and making it difficult to isolate and test individual components, as they rely on the presence and behavior of other components.

Interfaces and dependency injection make code more testable in the following ways:

Interfaces

  • Abstraction: Interfaces provide a way to define contracts for components without specifying their implementation. This allows you to create mock implementations for testing purposes.
  • Decoupling: By programming to an interface rather than a concrete class, you reduce the dependency on specific implementations, making it easier to swap out components.

Dependency Injection (DI)

  • Control over dependencies: DI allows you to inject dependencies into a class rather than having the class create them itself. This makes it easier to replace real dependencies with mocks or stubs during testing.
  • Isolation: DI helps in isolating the unit of code under test by providing mock implementations of its dependencies, making it easier to test the class in isolation.
  • Flexibility: DI containers can manage the lifecycle and configuration of dependencies, making it easier to manage complex dependency graphs and ensuring that tests can be set up and torn down cleanly.

Interfaces and DI together make code more modular, flexible and testable.

Our Legacy Code Example

Before introducing DI, let me explain my sample application. It has a document management service that handles file storage and a service that provides user access controls.

The document management service, DocumentService, handles document operations like upload, download, and deletion and currently uses an Azure SQL Database for document storage. The tight coupling between the service and the application is blocking support for other document storage methods and other cloud providers.

The user access service, UserService, manages user permissions and access to documents and also uses an Azure SQL Database for storing users, groups, and permissions. The tight coupling between the service and the application is blocking support for other authentication frameworks, such as Entra, Auth0, etc.

More importantly, the application is difficult to test due to the coupling between the application and the document and user services. Any application behavior that depends on the document service or the user service requires a running instance of SQL (hopefully not production) in order to test the application. These tests are, by definition, integration tests.

This is a common scenario in enterprise applications where improving testability and flexibility becomes crucial as the system grows.

Document Service

Here is the relevant code from the DocumentService class:

public class DocumentService : IDocumentService
{
    private readonly DocumentStorageAzureProvider _documentStorageProvider = new();
    
    // Implementation...
}

In the class the internal variable _documentStorageProvider is assigned a new instance of the DocumentStorageAzureProvider class. This tightly couples instances of the DocumentService class to instances of the DocumentStorageAzureProvider class. Any test of the DocumentService class will execute the methods of the DocumentStorageAzureProvider class and will need to contend with any side effects introduced by the provider class. To be more testable, the DocumentService class should be able to use any another class that implements the needed methods of the provider class in any way that satisfies the needs of the service class.

Further, once the service is decoupled from the provider, it's possible to use a different concrete implementation of the provider for another purpose without changing the service. For example the DocumentStorageAzureProvider class could be replaced with a DocumentStorageAWSProvider class.

User Service

Similarly to DocumentService, the UserService class is tightly coupled to the DocumentStorageAzureProvider class and we want to be able to address coupling throughout the solution.

public class UserService : IUserService
{
    private readonly DocumentStorageAzureProvider _documentStorageProvider = new();

    // Implementation...

}

To loosen the coupling with the provider, we need to remove the instantiation of the provider from the services and give the services an instance of the provider to use. This is what DI is for.

Of course, we could refactor this code to add dependency injection manually. Using GitHub Copilot can significantly speed up the implementation. Copilot is particularly helpful when working with legacy code because it can:

  1. Analyze the entire codebase context and suggest consistent changes across multiple files.
  2. Generate boilerplate DI configuration code that follows best practices.
  3. Help identify all the places where dependency injection needs to be introduced.
  4. Provide example test implementations that work with the new DI structure.

Let's see how Copilot can assist us with this refactoring.

Leveraging Copilot to Make Code More Testable

In addition to writing docs and generating unit tests, Copilot can also help us with larger tasks such as adding DI to an existing code base.

@Workspace Prompts

Given the prompt: @Workspace Agent how do I add dependency injection to this solution?

Copilot will use the entire codebase as the context and generate step-by-step instructions, with suggested code modifications.

The prompt: @Workspace Agent implement dependency injection in this solution

Copilot will produce modified versions of the code that you can choose to accept or reject. Let's look at the response from the first prompt.

Workspace Agent Response

What follows is the response from Copilot to the first prompt. My comments appear in italics after the code.

To add dependency injection to your solution, you need to register your services in the dependency injection container and then inject them where needed. Here's a step-by-step guide to achieve this:

  1. Register services in the DI container: Modify the ZeusStartup class to register your services in the DI container. This is typically done in the Builder method.
using Zeus.DocumentStorage;
using Zeus.Services.Contract;
using Zeus.Services.Implementation;

namespace Zeus.Host
{
   public static class ZeusStartup
   {
       public static string Builder(WebApplicationBuilder builder)
       {
           // Register services
           builder.Services.AddSingleton<IDocumentStorage, DocumentStorageAzureProvider>();
           builder.Services.AddScoped<IUserService, UserService>();
           builder.Services.AddScoped<IDocumentService, DocumentService>();

           // Other configurations...

           return "Builder configuration complete";
       }

       // Other methods...
   }
}

This is somewhat specific to the solution and the DI framework. In ASP.NET, the WebApplicationBuilder class provides a DI container and the ability to register services with the container. In this case, the suggestion is register the DocumentStorageAzureProvider class as a singleton meaning a single instance will serve all requests. The services are registered as scoped to the request and not shared between requests. Of note is that by registering the DocumentStorageAzureProvider class as the implementation of the IDocumentStorage interface, the existing behavior is preserved.

  1. Inject services into constructors: Modify your classes to accept the dependencies via constructor injection.

For example, in UserService:

public class UserService : IUserService
{
   private readonly IDocumentStorage _documentService;

   public UserService(IDocumentStorage documentService)
   {
       _documentService = documentService;
   }

   // Methods...
}

Here the DocumentStorageAzureProvider class is replaced with the IDocumentStorage interface and the variable _documentService will accept any instance that implements this interface. Copilot also suggests that a constructor be added that accepts a parameter that implements the IDocumentStorage interface and assigns it to the _documentService variable. Also of note is that we are not required to pass to the constructor an IDocumentStorage instance. When the UserService is instantiated, the DI container will pass an instance of the registered class to the UserService constructor for us.

  1. Update Program.cs: Ensure that the ZeusStartup.Builder method is called in your Program.cs to configure the services.
var builder = WebApplication.CreateBuilder(args);

// Call the Builder method to register services
ZeusStartup.Builder(builder);

var app = builder.Build();

// Call the App method to configure the app
ZeusStartup.App(app, "your-cors-origins");

app.Run();

Finally, Copilot suggests adding code to the startup program to call the builder to register the services with the container.

Conclusion

The Copilot suggestions implement DI while preserving the existing behavior. We can now go forward with implementing tests that mock the provider, isolating the service from the provider implementation. This allows us to test the service without a concern for the provider implementation. We can also implement a new provider and invoke it by registering it with the DI container.

The code is more testable and the tests are simpler to implement. The code is also more flexible in that we can easily provide other implementations. Copilot was there to help us find the code that needed to change and to suggest the changes.

Feedback Loop

Feedback is always welcome. Please direct it to john.miller@codemag.com

Disclaimer

AI contributed to the writing to this blog post, but humans reviewed it, refined it, enhanced it and gave it soul.

Prompts:

  • @Workspace Agent how do I add dependency injection to this solution?
  • What are some reason why code is not testable?
  • What is a definition of tight coupling
  • How do interfaces and dependency injection make code more testable?