Verifying your DI Container

30/04/2023
.NETC#

Microsoft's integrated dependency injection (short DI) container is very powerful, but there are also certain pitfalls. In this article, I will show you what some of the pitfalls are and how you can verify them.

The "problem"

Imagine you don't have a DI container:

public class MyService
{
    private readonly IMyDependency _myDependency;

    public MyService(IMyDependency myDependency)
    {
        _myDependency = myDependency;
    }
}

And somewhere in your code, you create an instance like this: var myService = new MyService(new MyDependency());. Further down the line, you have to add another dependency to your service:

public class MyService
{
    private readonly IMyDependency _myDependency;
    private readonly IMyOtherDependency _myOtherDependency;

    public MyService(IMyDependency myDependency, IMyOtherDependency myOtherDependency)
    {
        _myDependency = myDependency;
        _myOtherDependency = myOtherDependency;
    }
}

In a world without DI, you will get a compiler error as a dependency is now missing. But if you are using a container, you no longer get the compile time error. This is where our story begins: The DI container would throw an exception that it can not find a dependency for IMyOtherDependency if we forgot to register it, but it does that when we ask for the instance of the application. This isn't necessary at the startup of our application. So let's change that. We want to verify certain aspects of our container at startup.

The "problem" examples

Here are some problematic DI constructs I want to catch:

// Ohoh - a singleton with a dependency on a scoped service -> captive dependency
public class SingletonService
{
    private readonly TransientService _transientService;

    public SingletonService(TransientService transientService)
    {
        _transientService = transientService;
    }
}
public class TransientService { }

// Service with a missing dependency 
public class ServiceWithMissingDependency
{
    public ServiceWithMissingDependency(MissingDependency missingDependency)
    {
    }
}

// Does not get registered in the service collection
public class MissingDependency
{
}

var services = new ServiceCollection();

services.AddScoped<TransientService>();
services.AddSingleton<SingletonService>();
services.AddScoped<ServiceWithMissingDependency>();

Two problems here:

  1. We have unregistered dependencies (the MissingDependency)
  2. We have captive dependencies (the SingletonService with the dependency on the TransientService)

The solution

A word of warning here: This is an educational solution that works in many cases, but not all. Furthermore, I would suggest running this only in your development environment or debug build. Running this in production is not a good idea as it will slow down your startup time (we materialize the whole container and create many instances).

One of my favorite constructs in C#: Extension methods. We can use them to extend the IServiceCollection with a new method that does the verification for us:

public static class ServiceCollectionExtensions
{
    public static void Verify(this IServiceCollection services)

Verify throws if we find any issue. I don't want to fail fast. I want to collect all errors before showing this to the user, so we need a small place holder:

public class VerifyResult
{
    public bool IsValid => Errors.Count == 0;
    public List<string> Errors { get; } = new();

    public override string ToString()
    {
        var sb = new StringBuilder();
        sb.AppendLine($"Found {Errors.Count} error(s):");
        for (var i = 0; i < Errors.Count; i++)
        {
            sb.AppendLine($"{i + 1}. {Errors[i]}");
        }

        return sb.ToString();
    }
}

The general idea will look like this:

var result = new VerifyResult();
using var serviceProvider = services.BuildServiceProvider();

result.Errors.AddRange(CheckServicesCanBeResolved(serviceProvider, services));
result.Errors.AddRange(CheckForCaptiveDependencies(services));

if (!result.IsValid)
{
    throw new InvalidOperationException(result.ToString());
}

We build the service provider and then check for the two issues we have. Let's start with missing dependencies. For that we literally go through all services that are registered in the container and check if we can resolve them. Again a word of advise: This may lead to issue in certain corner cases where your constructor does something that is only supposed to happen later (for example when a controller is called or so). As we call the constructor, we really create instances.

private static List<string> CheckServicesCanBeResolved(IServiceProvider serviceProvider, IServiceCollection services)
{
    var unresolvedTypes = new List<string>();
    foreach (var serviceDescriptor in services)
    {
        try
        {
            serviceProvider.GetRequiredService(serviceDescriptor.ServiceType);
        }
        catch
        {
            unresolvedTypes.Add($"Unable to resolve '{serviceDescriptor.ServiceType.FullName}'");
        }
    }

    return unresolvedTypes;
}

That was straightforward. The next part is getting captive dependencies:

private static IEnumerable<string> CheckForCaptiveDependencies(IServiceCollection services)
{
    var singletonServices = services
        .Where(descriptor => descriptor.Lifetime == ServiceLifetime.Singleton)
        .Select(descriptor => descriptor.ServiceType);

    foreach (var singletonService in singletonServices)
    {
        var captiveScopedServices = singletonService
            .GetConstructors()
            .SelectMany(property => property.GetParameters())
            .Where(propertyType => services.Any(descriptor => descriptor.ServiceType == propertyType.ParameterType
                                                              && descriptor.Lifetime == ServiceLifetime.Scoped
                                                              || descriptor.Lifetime == ServiceLifetime.Transient));

        foreach (var captiveService in captiveScopedServices)
        {
            yield return $"Singleton service '{singletonService.FullName}' has one or more captive dependencies: {string.Join(", ", captiveService.ParameterType.FullName)}";
        }
    }
}

We get all singleton services and then check their constructors for dependencies that are scoped or transient. If we find any, we add them to our result. You could do the same with transient services and check for scoped dependencies. I left it out for brevity.

Results

This code:

var services = new ServiceCollection();

services.AddScoped<TransientService>();
services.AddSingleton<SingletonService>();
services.AddScoped<ServiceWithMissingDependency>();

services.Verify();

Will give us the following result:

Unhandled exception. System.InvalidOperationException: Found 2 error(s):
1. Unable to resolve 'ServiceCollectionVerify.ServiceWithMissingDependency'
2. Singleton service 'ServiceCollectionVerify.SingletonService' has one or more captive dependencies: ServiceCollectionVerify.TransientService

Conclusion

This is a simple way to verify your DI container at startup. It is not perfect and it will not catch all issues. But it is a good start. As always, the source code will follow!

Resources

  • Source code to this blog post: here
  • All my sample code is hosted in this repository: here
7
An error has occurred. This application may no longer respond until reloaded. Reload x