Resolving keyed services in .Net DI with reflection

Resolving keyed services in .Net DI with reflection

The problem

Extending standard .Net dependency injection mechanism with keyed services feature using the power of reflection.

Since the introduction of standardized Dependency Injection mechanism in .Net, it lacked a few things, that users of other well-established DI providers longed for. One of these features is the ability to assign a key to service implementation and resolve it using this key later on.

This way devs could have multiple implementations of the same interface ready, and just change the key when feasible.

This is how it is done in Autofac now:

// register named in container
var builder = new ContainerBuilder();
builder.RegisterType<OldApiService>().Named<IApiService>("v1");
builder.RegisterType<NewApiService>().Named<IApiService>("v2");
// ...
const string ApiVersion = "v2";
// Resolve directly
var apiService = container.ResolveNamed<IApiService>(ApiVersion);
// Or via attribute in constructor
public SuperbService([KeyFilter(ApiVersion)] IApiService apiService){ ... }

More in Autofac documentation.

If you want to learn the basics, good and bad of dependency injection check the official MS documentation page.

Idea

For now (.Net 7) there is no way of doing that. If you have multiple implementations of the same service, then you can do one of both:

  1. Derive a more specialized interface and use it in the constructor

     // Base interface
     public interface IRandomNumberService { }
    
     // Separate interface per implementation
     public interface IPositiveNumberService : IRandomNumberService { }
     public interface INegativeNumberService  : IRandomNumberService { }
    
     // Register implementations
     services.AddScoped<IPositiveNumberService, PositiveNumberService>()
       .AddScoped<INegativeNumberService, NegativeNumberService>();
    
     // Use specialized interfaces in constructors
     private readonly IRandomNumberService numberService;
     public ApiUsingService(INegativeNumberService numberService) { 
       this.numberService = numberService; // NegativeNumberService passed
     }
    

    This way we not only introduce an additional, purely artificial, layer of abstraction but also diminish the flexibility and openness of the code.

  1. We pick up one implementation from the list of abstractions

     // base service
     public interface IRandomNumberService { ... }
    
     // specific implementations
     public class PositiveNumberService : IRandomNumberService  { ... }
     public class NegativeNumberService : IRandomNumberService { ... }
    
     // Register implementations
     services.AddScoped<IRandomNumberService, PositiveNumberService>()
         .AddScoped<IRandomNumberService, NegativeNumberService>();
    
     // Get whole list and select one
     private readonly IRandomNumberService numberService;
     public ApiUsingService(IEnumerable<IRandomNumberService> services) { 
         this.numberService= SelectProperService(services);
     }
    

    This in a straight line leads to the use of an abstract factory pattern. It is a well-documented pattern, that allows for the use of complex rules for resolving implementation in runtime. However same as the previous solution, it adds another layer of abstraction and complexity to the code. It also might feel like too much overhead for simple cases.

    Sometimes in development, we just know what will be used where, and we just want to be able to put it there.

We lack something like what Autofac provides, it would make our lives much simpler.

Solution

I wanted to simplify the service registration by key even more.

We will use attributes on classes we want to register as keyed implementations. And another attribute to resolve these keyed dependencies.

In the end, it should look like this:

// specific implementations implementing base service
[ServiceKey("Positive")]
public class PositiveNumberService : IRandomNumberService  { ... }
[ServiceKey("Negative")]
public class NegativeNumberService : IRandomNumberService { ... }

// Get whole list and select one
private readonly IRandomNumberService numberService;
public GeneratorService([ServiceKey("Positive")] IRandomNumberService service)
{ 
    this.numberService = service;
}

// Register services
services
    .AddAllKeyedServices()         // registers all instances with keys
    .AddScopedWithKeyedDependencies<GeneratorService>();

Looks simple, doesn't it?

We will utilize the power of the .Net reflection mechanism to do that.

💡
If you do not know much about reflection, in short, it is a way to access and read information about object types and their members at runtime. Full MS docs can be read here.

Assigning keys

The first step will be simple. We just need to know which services should be resolved using keys and where to use them.

Just like in my previous article, we will use attributes to mark them.

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Parameter)]
public class ServiceKeyAttribute : Attribute
{
    public ServiceKeyAttribute(string key)
    {
        Key = key;
    }

    public string Key { get; }
}

A simple attribute with a key is enough, just make sure we can assign it to either the class or parameter of the constructor.

Registration

For the types marked with keys, we need to register them as implementation types, so we have access to their attributes with reflection. We will need it later.

services.AddScoped<PositiveNumberService>()
    .AddScoped<NegativeNumberService>();

We need to provide an extension method to register the classes that use keyed services. For resolving these instances we will use the custom factory method, which will be described further.

public static IServiceCollection AddWithKeyedDependencies<TService, TImpl>(
    this IServiceCollection services, ServiceLifetime lifetime)
        where TService : class
        where TImpl: class, TService =>
    services.Add(new ServiceDescriptor(
                typeof(TService), 
                // The factory method will be implemented soon
                (sp) => ResolveWithKeyedDependencies<TImpl>(services, sp),
                lifetime));

Services discovery

Now the tricky part. How to find the services with keys. This is where reflection becomes relevant. We can traverse type data: classes, attributes assigned to them, their constructors with parameters and attributes for them.

First let's add some helper methods to check, whether the given class has a key assigned:

public static bool HasServiceKey(this Type type) =>
    type.GetCustomAttribute<ServiceKeyAttribute>()?.Key is not null;

And if the constructor uses any keyed parameter.

public static bool UsesKeyedDependency(this ConstructorInfo cstr) =>
    cstr.GetParameters()
        .Any(param => param
                .GetCustomAttribute<ServiceKeyAttribute>() is not null);

Lastly, we check if the class uses any keyed parameter in any constructor.

public static bool UsesKeyedDependency(this Type type) =>
    type.GetConstructors().Any(UsesKeyedDependency);

This will come in handy shortly.

Services resolution

We have all the services marked with keyes, and used in constructors. It is time for the most complex part: pick up the service constructor and resolve any keyed parameter with the proper service instance.

Selecting constructor

There are two rules of how MS DI container selects service constructor for resolution, that we need to follow in our factory method to fully mimic its behavior:

  1. The constructor with the most number of resolvable dependencies is chosen by container.

  2. Constructor marked with ActivatorUtilitiesConstructorAttribute takes precedence before any other constructors.

    Update: Seems the ActivatorUtilitiesConstructor attribute is not respected if not declared as the first one in class. There were several bugs reported for that to the MS team. However, the fix will be available in .Net 8 along with more consistent overall resolution DI behavior.

So we order first the constructor with the aforementioned attribute if any, and then by the number of its dependencies descendingly. To access all this data we yet again rely heavily on reflection.

private static ConstructorInfo? GetConstructorForResolution(this Type type) =>
    type.GetConstructors()
        .OrderByDescending(ci => ci.GetCustomAttribute<ActivatorUtilitiesConstructorAttribute>() is not null)
        .ThenByDescending(ci => ci.GetParameters().Length)
        .FirstOrDefault();

Resolving parameters

We have the constructor available. Now let's go through its parameters and resolve them all. We do it using IServiceProvider instance, the standard way until we find one, that has a keyed attribute.

For the keyed ones that match the interface we resolve, we read the ImplementationType of them to find matching key value in the attribute. We can do it because in part before, we registered all keyed services by their implementation type, and can access it via reflection.

 private static Type FindResolvingType(ParameterInfo parameter, IServiceCollection services)
 {
    var parameterServiceKey = parameter.GetCustomAttribute<ServiceKeyAttribute>()!.Key;

    if (parameterServiceKey is null)
        return parameter.ParameterType;

    return services.FirstOrDefault(sd => 
        parameter.ParameterType.IsAssignableFrom(sd.ServiceType)
            && sd.ImplementationType?.GetCustomAttribute<ServiceKeyAttribute>()
                ?.Key == parameterServiceKey)?.ImplementationType 
                    ?? throw new InvalidOperationException($"Keyed dependency resolving type could not be determined for parameter of type '{ParameterType.FullName}'. Make sure you provided equal keys for service and parameter.");
 }
💡
IsAssignableFrom is a helpful Type method, which verifies if the compared type implements ServiceType interface. Make sure to check other reflection methods, you may be amazed, at what can be done with them.

Having all the pieces in place this is where we implement the factory method declared before.

public static TService ResolveWithKeyedDependencies<TService>(
    IServiceCollection services, IServiceProvider provider)
    where TService : class
{
    var constructor = GetConstructorForResolution(typeof(TService));
    if (constructor is null)
        throw new InvalidOperationException($"Could not find constructor to resolve for service {typeof(TService).FullName}.");

    var parametersValues = constructorInfo.GetParameters()
        .Select(p => serviceProvider.GetRequiredService(FindResolvingType(p, services)))
        .ToArray();

    return (TService)constructorInfo.Invoke(parametersValues);
}

We simply resolve each parameter type using the service provider instance and pass them in the array of values for the service constructor. We just invoke the constructor as any method and cast the result.

💡
Activator is another essential reflection tool, that allows the creation of new instances of any type in the runtime. Variation of it ActivatorUtilities is used internally by .Net dependency injection container to create service instances.

Final configuration

Having it all we can update our startup with all relevant registrations.

// Program.cs
// register all keyed services as implementation types
builder.Services
    .AddScoped<PositiveNumberService>()
    .AddScoped<NegativeNumberService>()
// register services using keyed services
    .AddScopedWithKeyedDependencies<ITemperatureGenerator, 
                                    WarmTemperatureGenerator>();
// which declares constructor like so:
// public WarmTemperatureGenerator([ServiceKey("Positive")] IRandomNumberService numberService) { ... }

That's all.

Alongside you can provide other registrations of services as usual, just make sure you do not override service using keyed dependencies.

Extending solution

There are many ways this solution can be extended. I described some of them below, but you can probably already think about your specific cases.

Registering all keyed services at once

To remove the burden of registering each keyed service separately, we can once again utilize reflection power and do them all at once.

We can iterate over all the types in application assembly and find all with keys assigned.

public static IServiceCollection AddAllKeyedServices(
    this IServiceCollection services) {
    AppDomain
           .CurrentDomain
           .GetAssemblies()
           .SelectMany(assembly => assembly.GetTypes())
           .Where(HasServiceKey)
           .ToList()
           .ForEach(type => services.Add(
                new ServiceDescriptor(type, type, ServiceLifetime.Scoped)));

    return services;
}

Defining service lifetime

The current solution is limited to the keyed services being registered in the 'Scoped' lifetime. We can easily add the possibility of managing the scope for each of them by extending our attribute by new property.

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Parameter)]
public class ServiceKeyAttribute : Attribute
{
    public ServiceKeyAttribute(string key, 
        ServiceLifetime lifetime = ServiceLifetime.Scoped)
    {
        Key = key;
        Lifetime = lifetime;
    }

    public string Key { get; }
    public ServiceLifetime Lifetime { get; }
}

And use it while iterating over types in the extension method from before.

Solution efficiency

We can move the check if the service can actually be resolved with keyed dependencies to the application startup. This way we would know at application startup that something is wrong and fix it right away, rather than investigating unexpected exceptions sometime later during application usage.

To do this we can beforehand collect all data about all keyed services and all services using keyed dependencies, and validate if there are no issues between them.

Collected data can be also reused in the factory method resolving service with keyed dependencies. This will save additional runs through reflected types.

I implemented this check in the extended version of the solution on my Git Hub. Check the details there.

Pros and cons of the solution

As with any approach in development, we must weigh its positive outcomes and possible drawbacks. This solution is not an exception.

Pros

  • Does not require changing the default container resolution provider.

  • Provides a clean and concise way of telling which service has key assigned, and where it is used with the use of the same attribute.

  • Does not change the functionality of keyed classes.

  • Works alongside default services resolution.

  • Any type of object can be used as a key. You are not limited to string only. It just needs to provide IEquatable interface implementation. For example Version class would be the one that comes to mind or the new .Net records.

Cons

  • The use of reflection comes with a bit of performance overhead. Running through the metadata of types, and traversing them down the syntax tree can be costly, especially for large projects. Use of it here will affect the application's startup time. Use it sparingly for as small a subset of data as possible.

  • You need to be very careful when messing up with reflection, mainly when creating new instances or executing their methods. There is no compiler, that will check used types, and point out any errors. Hence unexpected errors may happen at runtime, and pointing out their exact source might be hard.

  • Using the same service with a different key does not protect us from circular dependency resolution issue. So the below code even though resolvable in our minds will still throw an exception in real life.

      [ServiceKey("Positive")]
      public class PositveNumberService : IRandomNumberService { ... }
    
      [ServiceKey("Negative")]
      public class NegativeNumberService : IRandomNumberService 
      { 
          public NegativeNumberService(
              [ServiceKey("Positive")]IRandomNumberService numberService) { ... }
      }
    
      // Program.cs
      builder.Services.AddAllKeyedServices()
          .AddScopedWithKeyedDependencies<IRandomNumberService, NegativeNumberService>();
    
      var app = builder.Build(); // <-- ERROR: Circular dependency
    

    The .Net DI container checks all the dependencies on startup and decides if they can be resolved. But it does not understand that different keys mean different instances of IRandomNumberService. It sees the same interface in the dependency tree, therefore it throws an exception.

Keyed services in .Net 8

If you follow .Net news, you might already know that finally in the next .Net version 8, we will have the keyed services functionality built into the regular dependency framework. That is great news, and if you want to read more about it you may want to read:

I did not know about this when this article idea came to my mind. I still decided to write it when I learned about this new feature.

  1. Still, many working projects built on .Net version 7 or lower might never be migrated to the newest version. Be that due to process- or risk-related reasons. They can freely leverage the described solution to have similar functionality. Given a few minor tweaks the code here works with apps dating back to .Net core.

  2. Even with incoming changes ideas provided here might be used to facilitate keyed services usage in code in .Net 8. We can use the attribute form here to mark all instances in code and register them all at once on startup.

Lessons learned

Haven't used reflection to that extent for some time. Did not really need it in many cases be it in commercial or private projects. But when it becomes useful it truly shows potential and sometimes is the only tool, that can help in very specific cases.

It comes with a bit of a curve to learn with all the types and helpers. But it is totally worth the effort.

If you are deeper in .Net then you might have noticed, that recently lots of cases covered by reflection are now being taken over by source generators. This one could be done like this too, but the learning curve there is much higher and it will take a few of the articles like this.

Given that keyed services will be provided by .Net 8 I refrained from following source generators solution on keyed services. But for sure expect some day series about source/incremental generators, as I want to learn them from top to bottom. Just need interesting and useful case for them.

Github repo

A repository with a fully working example is free for you to check on Git Hub.

https://github.com/slamcodeblog/KeyedServices.Reflection

I extended the solution by some models collecting and keeping all data needed for resolution beforehand, to make it more efficient. Added Lifetime property to the ServiceKeyAttribute and used Scrutor to register all keyed services at once. Be sure to check it out.

Thank you!