Search code examples
c#asp.net-coredependency-injectionasp.net-core-8

How to get a dictionary of keyed services in ASP.NET Core 8


In ASP.NET Core 8, we have the option to register services using a key. Typically, injecting IEnumerable<ICustom> in a constructor will return all services that are registered of the ICustom.

But, how can I resolve a dictionary of the service along with their keys? For example, IDictionary<string, ICustom> so I can access both the service and its key?


Solution

  • There's nothing included OOTB to get a dictionary of keyed services, but you can add this behavior using the following extension method:

    // License: This code is published under the MIT license.
    // Source: https://stackoverflow.com/questions/77559201/
    public static class KeyedServiceExtensions
    {
        public static void AllowResolvingKeyedServicesAsDictionary(
            this IServiceCollection sc)
        {
            // KeyedServiceCache caches all the keys of a given type for a
            // specific service type. By making it a singleton we only have
            // determine the keys once, which makes resolving the dict very fast.
            sc.AddSingleton(typeof(KeyedServiceCache<,>));
            
            // KeyedServiceCache depends on the IServiceCollection to get
            // the list of keys. That's why we register that here as well, as it
            // is not registered by default in MS.DI.
            sc.AddSingleton(sc);
            
            // Last we make the registration for the dictionary itself, which maps
            // to our custom type below. This registration must be  transient, as
            // the containing services could have any lifetime and this registration
            // should by itself not cause Captive Dependencies.
            sc.AddTransient(typeof(IDictionary<,>), typeof(KeyedServiceDictionary<,>));
            
            // For completeness, let's also allow IReadOnlyDictionary to be resolved.
            sc.AddTransient(
                typeof(IReadOnlyDictionary<,>), typeof(KeyedServiceDictionary<,>));
        }
    
        // We inherit from ReadOnlyDictionary, to disallow consumers from changing
        // the wrapped dependencies while reusing all its functionality. This way
        // we don't have to implement IDictionary<T,V> ourselves; too much work.
        private sealed class KeyedServiceDictionary<TKey, TService>(
            KeyedServiceCache<TKey, TService> keys, IServiceProvider provider)
            : ReadOnlyDictionary<TKey, TService>(Create(keys, provider))
            where TKey : notnull
            where TService : notnull
        {
            private static Dictionary<TKey, TService> Create(
                KeyedServiceCache<TKey, TService> keys, IServiceProvider provider)
            {
                var dict = new Dictionary<TKey, TService>(capacity: keys.Keys.Length);
    
                foreach (TKey key in keys.Keys)
                {
                    dict[key] = provider.GetRequiredKeyedService<TService>(key);
                }
    
                return dict;
            }
        }
    
        private sealed class KeyedServiceCache<TKey, TService>(IServiceCollection sc)
            where TKey : notnull
            where TService : notnull
        {
            // Once this class is resolved, all registrations are guaranteed to be
            // made, so we can, at that point, safely iterate the collection to get
            // the keys for the service type.
            public TKey[] Keys { get; } = (
                from service in sc
                where service.ServiceKey != null
                where service.ServiceKey!.GetType() == typeof(TKey)
                where service.ServiceType == typeof(TService)
                select (TKey)service.ServiceKey!)
                .ToArray();
        }
    }
    

    After adding this code to your project, you use it as follows:

    services.AllowResolvingKeyedServicesAsDictionary();
    

    This allows you to resolve keyed registrations as an IDictionary<TKey, TService>. For instance, take a look at this fully working console application example:

    using Microsoft.Extensions.DependencyInjection;
    
    var services = new ServiceCollection();
    
    services.AllowResolvingKeyedServicesAsDictionary();
    
    services.AddKeyedTransient<IService, Service1>("a");
    services.AddKeyedTransient<IService, Service2>("b");
    services.AddKeyedTransient<IService, Service3>("c");
    services.AddKeyedTransient<IService, Service4>("d");
    services.AddKeyedTransient<IService, Service5>(5);
    
    var provider = services.BuildServiceProvider(validateScopes: true);
    
    using (var scope = provider.CreateAsyncScope())
    {
        var stringDict = scope.ServiceProvider
            .GetRequiredService<IDictionary<string, IService>>();
    
        Console.WriteLine("IService registrations with string keys:");
        foreach (var pair in stringDict)
        {
            Console.WriteLine($"{pair.Key}: {pair.Value.GetType().Name}");
        }
    
        var intDict = scope.ServiceProvider
            .GetRequiredService<IReadOnlyDictionary<int, IService>>();
    
        Console.WriteLine("IService registrations with int keys:");
        foreach (var pair in intDict)
        {
            Console.WriteLine($"{pair.Key}: {pair.Value.GetType().Name}");
        }
    }
    
    public class IService { }
    
    public class Service1 : IService { }
    public class Service2 : IService { }
    public class Service3 : IService { }
    public class Service4 : IService { }
    public class Service5 : IService { }
    

    When running, the application will produce the following output:

    IService registrations with string keys:
    a: Service1
    b: Service2
    c: Service3
    d: Service4
    IService registrations with int keys:
    5: Service5
    

    Notes:

    • At the time of writing, there is a discussion in dotnet/runtime about adding this feature to the runtime.
    • Disadvantage of the implementation above, is that it resolves all available keyed services when the dictionary is resolved. This can be highly inefficient when a large number of keyed services is registered.
    • Advantage of resolving all keyed services up front, on the other hand, is that MS.DI will throw an exception at the point where a consumer of the dictionary is resolved, in case there is a Captive Dependency detected. A lazy-loading implementation will likely always cause the exception—only—when the the keyed service is first retrieved from the dictionary — unless there is deep framework support for this. When implemented in the framework, it could even ensure that Captive Dependencies are detected when the container is built (using BuildServiceProvider), which not happening with the above implementation.