Getting Dependencies by Name or Key using the .NET Core Container (Part 2)

If you have come here from a search engine, I suggest reading Part 1 first to get some background as to the problem that this post solves.

In Part 1, I gave the background as to why you may have a requirement to have a way of resolving instances from a DI container using a string name or some other key.

In this part, I propose a solution that leaves your code ignorant of any DI container and that is easily mockable for unit tests.

Making Use of Strongly Typed Delegates

A key thing to understand about DI containers is that the service type that is registered is exactly that – a type, not just interfaces and classes. Therefore, you can register delegates as well.

The idea of using delegates for resolution is not new.  There is an example in the Autofac documentation and this article on c-sharpcorner.

However, for .NET Core, when you search for something like ‘.NET Core named dependency injection’, you tend to only find the answer buried in StackOverflow answers.

That is why I have written this post, so that I can find the answer later myself, and also hopefully help other find it too.

Func<T> Delegates Vs. Strongly Typed Delegates

Before getting into a detail of using delegates as service types, we need to talk about generic Func delegates vs strongly typed delegates.

The generic Func type was introducted in C# 3.0 and is a generic delegate included in the System namespace that has multiple overloads that accept between zero and sixteen input parameters (denoted T1 to T16) and a single output parameter (TResult). These are convenience types so that as a developer you do not need to repeatedly create your own customer delegates.

For the purposes of retrieving an instance of a type from the DI container, the various Func delegates can be used as the type for service registration and as a parameter to the constructors of classes. E.g. Func<string, IServiceType>.

However, I have two reasons for not using this approach.

  1. If you change the signature of the service registration to a different overload of Func, you have no tools in the IDE to reflect that change throughout all the references (as the dependency resolution does not take place until runtime). Instead you will need to perform a text ‘search and replace’ task across your whole code base (and hope that there are no other uses of the generic signature for other purposes). By using a strongly typed delegate, this is a unique type and therefore, when changing the signature of the delegate, the compiler will indicate when references and instances are broken (or a tool like Reshaper will do the work for you)
  2. Though this is probably a very rare occurrence, you may have a need to have two different factories with the same signature. If the generic Func<T, IServiceType> is used, it will not be possible to differentiate the two factories. With strongly typed delegates, it is the delegate type that is registered and referenced in the constructor, so the same signature can be used with two different delegate types and the DI container will resolve tHem correctly.

How Does Using Typed Delegates Help?

By using typed delegates, the code that requires the named/keyed dependency is ignorant of the DI container technology that is resolving the instance for it.

This differs from other approaches where either the consuming class itself or an intercepting factory class takes the IServiceProvider as a dependency in its constructor and has logic to resolve the instance itself which defeats the purpose of dependency injection as it is creating a glue between the classes.

By registering the delegate within the DI container registration code, it keeps the codebase (outside of Di registration) ignorant of how the name/key resolution is working.

The Demo

For the rest of this post, I am going to use a test project (which can be found on GitHub at https://github.com/configureappio/NamedDiDemo) that converts temperatures to the Kelvin scale from four different scales – Centigrade, Fahrenheit, Rankine and Kelvins.

To do this, there are four formulas that are needed

Calculation Formula
Celcius(°C) to Kelvin(K) T(K) = T(°C) + 273.15
Fahrenheit(°F) to Kelvin(K) T(K) =  (T(°F) + 459.67) x 5 / 9
Rankine to Kelvin(K) T(K) = T(°R) × 5/9
Kelvin(K) to Kelvin(K) T(K) = T(K)

Each formula is implemented as a mapper class with an implementation of the IKelvinMapper interface as defined below.

Each of the implementations is registered with the DI container

For the caller to retrieve the correct conversion, we need a delegate that uses the input temperature scale in order to retrieve the correct mapper and return the mapper as the IKelvinMapper interface

Lastly, a lookup function is registered with the DI container against the delegate type as the service type to perform the lookup of the correct mapper. To keep things simple, the lookup is based on the first (capitalised) letter of the input temperature scale (normally as more robust lookup would be used, but I’ve tried to keep it simple so as not to distract from the core purpose of what I am demonstrating).

With the returned interface implementation, a conversation from the input temperature scale and value can be made to a consistent value in the Kelvin scale.

So How Does It Work?

The demo works on a very simple basis of leveraging the DI container to do all the hard work of determining which mapping converter is required. The caller just needs to know that it uses the delegate to take in the input temperature scale and value and return the value in Kelvins.

Object Instantiation

Depending on the complexity of the type to be instantiated, there are three ways to instantiate the object:

  1. Perform the instantiation with a ‘new’ keyword. This is fine if the constructor of the type has no dependencies and a new instance is required on each occasion. However, I would discourage this approach as it is not an efficient way of managing the lifetimes of the objects and could lead to problems with garbage collection due to long lived objects
  2. Rely on the DI container to instantiate the object. This is the approach taken in the demo. This has two benefits. The first is that if the object can be a singleton, then the DI container takes care of that for you provided the type has been registered as a singleton lifetime when registered. The second is that if the type has dependencies in the constructor, the DI container will resolve these for you
  3. Lastly, if one or more parameters to the type constructor need the be generated from the delegate input (and therefore not directly resolvable from prior DI registrations), but other parameters can be resolved, then the ActivatorUtilities CreateInstance method can be used to create an instance using a hybrid of provided parameters and using the DI container to resolve those parameters not provided.

This is where you take advantage of the ability to register a service resolution using a lambda expression as it provides access to the IServiceProvider container instance to retrieve instances from the service provider for (2) and (3) above.

Deciding Which Type to Instantiate

For simple keys, you can use a case statement inside a lambda expression to derive the type required and let the DI container perform the object instantiation for you. (This is the approach I have used in the demo project).

However, if you have many different items or complex keys, you may want to consider other ways of deriving the service type required.

For example, by registering a singleton Dictionary<string, serviceType> to map keys to types, if there are many keys, you may gain a performance boost from the hash lookup algorithm.

Instead of using the out-of-the-box generic dictionary thought, I recommend creating a dedicated class that implements IDictionary<string, Type> for the same reasons that I recommend using custom delegates over the generic Func<T> – it removes any ambiguity if there is (deliberately or inadvertently) more than one registration of the generic dictionary.

Care with Scoped Lifetimes

Careful thought needs to be given to the lifetime of both the factory delegate and the object being retrieved from the DI container.

Typically, as factory classes have no state,  they can usually be registered with a singleton lifetime. The same applies to the delegate being used as the factory or to access the factory class.

Where things get complicated is when the service type that the factory is instantiating for you has been registered in the DI container as having a scoped lifetime.

In this case, the resolution within the factory fails as the DI container cannot resolve scoped dependencies for constructor injection inside a singleton or transient lifetime object (in our case, the singleton factory delegate).

There are two ways to get around this problem.

(i) Register the factory delegate as scoped as well

(ii) Change the singleton factory delegate to take an IServiceProvider as part of the function signature. It is then the caller’s responsibility to pass the correctly scoped IServiceProvider to the delegate. However, this effectively takes us back to a service locator pattern again.

Accessing the Delegate for Calling

Now that we have abstracted the mapper instance generation into a delegate function, the client just needs to be able to resolve the instance.

For classes that want to use the mapper (and are also registered with the DI container), this is simply a case of making the delegate a constructor parameter.

Conclusion

Hopefully, these two posts have provided some insight into how the limitations of the Microsoft DI container to support named or keyed resolutions can be worked around without having to resort to using a third-party container or custom service locator pattern implementation.

Whilst not part of this two-part blog post, I have written another post about simplifying the demo to work in a more functional way by replacing the interface implementations with direct functions to (a) reduce the amount of code and (b) reduce the number of memory allocations required to create the objects.

Leave a Reply

Your email address will not be published. Required fields are marked *