Skip to content

A keyed services implementation for Microsoft.Extensions.DependencyInjection

License

Notifications You must be signed in to change notification settings

commonsensesoftware/more-keyed-services

Repository files navigation

MIT licensed CI

Overview

An opinionated implementation for keyed services using Microsoft.Extensions.DependencyInjection using a Type as universal key as opposed other approaches such as magic strings.

This project supplants the existing Keyed Services POC repository.

Packages

  • More.Extensions.DependencyInjection.Keyed (Default, Autofac, LightInject, and Unity)
    NuGet Package NuGet Downloads

  • More.DryIoc.Extensions.DependencyInjection.Keyed
    NuGet Package NuGet Downloads

  • More.Grace.Extensions.DependencyInjection.Keyed
    NuGet Package NuGet Downloads

  • More.Lamar.Extensions.DependencyInjection.Keyed
    NuGet Package NuGet Downloads

  • More.Stashbox.Extensions.DependencyInjection.Keyed
    NuGet Package NuGet Downloads

  • More.StructureMap.Extensions.DependencyInjection.Keyed
    NuGet Package NuGet Downloads

Background and Motivation

The main reason this has not been supported is that IServiceProvider.GetService(Type type) does not afford a way to retrieve a service by key. IServiceProvider has been the staple interface for service location since .NET 1.0 and changing or ignoring its well-established place in history is a nonstarter. However... what if we could have our cake and eat it to? 🤔

A keyed service is a concept that comes up often in the IoC world. All, if not almost all, DI frameworks support registering and retrieving one or more services by a combination of type and key. There are ways to make keyed services work in the existing design, but they are clunky to use (ex: via Func<string, T>). The following proposal would add support for keyed services to the existing Microsoft.Extensions.DependencyInjection.* libraries without breaking the IServiceProvider contract nor requiring any container framework changes.

API Design

The API design was originally put forth as a proposal for keyed services in .NET Issue #64427. The proposal was rejected in favor of IKeyedServiceProvider, which will be added in .NET 8. This project will leverage those change to improve integration after .NET 8.

The first requirement is to define a key for a service. Type is already a key. This project will use the novel idea of also using Type as a composite key. This design provides the following advantages:

  • No magic strings
  • No attributes or other required metadata
  • No hidden service location lookups (e.g. a la magic string)
  • No name collisions (types are unique)
  • No additional interfaces required for resolution (ex: ISupportRequiredService)
  • No changes to IServiceProvider
  • No changes to ServiceDescriptor
  • No implementation changes to the existing containers
  • No additional library references (from the BCL or otherwise)
  • Resolution intuitively fails if a key and service combination does not exist in the container
  • Container implementations can be swapped freely without the worry of incompatible key types

Resolving Services

To resolve a keyed dependency we'll define the following contracts:

// required to 'access' a keyed service via typeof(T)
public interface IKeyed
{
    object Value { get; }
}

public interface IKeyed<in TKey, out TService> : IKeyed
    where TService : notnull
{
    new TService Value { get; }
}

The following extension methods are added to ServiceProviderServiceExtensions:

public static class ServiceProviderServiceExtensions
{
    public static object? GetService(
        this IServiceProvider serviceProvider,
        Type serviceType,
        Type key);

    public static object GetRequiredService(
        this IServiceProvider serviceProvider,
        Type serviceType,
        Type key);

    public static IEnumerable<object> GetServices(
        this IServiceProvider serviceProvider,
        Type serviceType,
        Type key);

    public static TService? GetService<TKey, TService>(
        this IServiceProvider serviceProvider)
        where TService : notnull;

    public static TService GetRequiredService<TKey, TService>(
        this IServiceProvider serviceProvider)
        where TService : notnull;

    public static IEnumerable<TService> GetServices<TKey, TService>(
        this IServiceProvider serviceProvider)
        where TService : notnull;
}

Registering Services

Now that we have a way to resolve a keyed service, how do we register one? Type is already used as a key, but we need a way to create an arbitrary composite key. To achieve this, we'll perform a little trickery on the Type which only affects how it is mapped in a container; thus making it a composite key. It does not change the runtime behavior nor require special Reflection magic. We are effectively taking advantage of the knowledge that Type will be used as the gatekeeper for a key used in service resolution for all container implementations. A specific container implementation does not need to actually use Type as many containers already use String or some other type.

public static class KeyedType
{
    public static Type Create(Type key, Type type) =>
        new KeyedTypeInfo(key,type);
    
    public static Type Create<TKey, TType>() where TType : notnull =>
        new KeyedTypeInfo(typeof(TKey), typeof(TType));

    public static bool IsKey(Type type) => type is KeyedTypeInfo;

    private sealed class KeyedTypeInfo :
        TypeInfo,
        IReflectableType,
        ICloneable,
        IEquatable<Type>,
        IEquatable<TypeInfo>,
        IEquatable<KeyedTypeInfo>
    {
        private readonly Type key;
        private readonly Type type;

        public KeyedTypeInfo(Type key, Type type)
        {
            this.key = key;
            this.type = type;
        }

        public override int GetHashCode() => HashCode.Combine(key, type);

        // remainder omitted for brevity
    }
}

This might look magical, but it's not. Type is already being used as a key when it's mapped in a container. KeyedTypeInfo has all the appearance of the original type, but produces a different hash code when combined with another type. This affords for determinate, discrete unions of type registrations, which allows mapping the intended service multiple times.

Container implementers are free to perform the registration however they like, but the generic, out-of-the-box implementation would look like:

public sealed class Keyed<TKey, TService> : IKeyed<TKey, TService>
    where TService : notnull
{
    public Dependency(IServiceProvider serviceProvider) =>
        Value = (TService)serviceProvider.GetRequiredService(Key);

    private static Type Key => KeyedType.Create<TKey, TService>();

    public TService Value { get; }

    object IDependency.Value => Value;
}

Container implementers might provide their own extension methods to make registration more succinct, but it is not required. The following registration would work without any fundamental changes:

public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton(KeyedType.Create<Key.Thing1, IThing>(), typeof(Thing1));
    services.AddTransient<IKeyed<Key.Thing1, IThing>, Keyed<Key.Thing1, IThing>>();
}

There is a minor drawback of requiring two registrations per keyed service in the container. The second registration should always be transient. The type IKeyed{TKey, TService} is just a holder used to resolve the underlying service. There is no reason for it to hold state. The underlying value holds the service instance according to the configure lifetime policy.

var longForm = serviceProvider.GetRequiredService<IKeyed<Key.Thing1, IThing>>().Value;
var shortForm = serviceProvider.GetRequiredService<Key.Thing1, IThing>();

The following extension methods will be added to provide common registration through IServiceCollection for all container frameworks:

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddSingleton<TKey, TService, TImplementation>(
        this IServiceCollection services)
        where TService : class
        where TImplementation : class, TService;

    public static IServiceCollection AddSingleton(
        this IServiceCollection services,
        Type keyType,
        Type serviceType,
        Type implementationType);

    public static IServiceCollection TryAddSingleton<TKey, TService, TImplementation>(
        this IServiceCollection services)
        where TService : class
        where TImplementation : class, TService;

    public static IServiceCollection TryAddSingleton(
        this IServiceCollection services,
        Type keyType,
        Type serviceType,
        Type implementationType);

    public static IServiceCollection AddTransient<TKey, TService, TImplementation>(
        this IServiceCollection services)
        where TService : class
        where TImplementation : class, TService;

    public static IServiceCollection AddTransient(
        this IServiceCollection services,
        Type keyType,
        Type serviceType,
        Type implementationType);

    public static IServiceCollection TryAddTransient<TKey, TService, TImplementation>(
        this IServiceCollection services)
        where TService : class
        where TImplementation : class, TService;

    public static IServiceCollection TryAddTransient(
        this IServiceCollection services,
        Type keyType,
        Type serviceType,
        Type implementationType);

    public static IServiceCollection AddScoped<TKey, TService, TImplementation>(
        this IServiceCollection services)
        where TService : class
        where TImplementation : class, TService;

    public static IServiceCollection AddScoped(
        this IServiceCollection services,
        Type keyType,
        Type serviceType,
        Type implementationType);

    public static IServiceCollection TryAddScoped<TKey, TService, TImplementation>(
        this IServiceCollection services)
        where TService : class
        where TImplementation : class, TService;

    public static IServiceCollection TryAddScoped(
        this IServiceCollection services,
        Type keyType,
        Type serviceType,
        Type implementationType);

    public static IServiceCollection TryAddEnumerable<TKey, TService, TImplementation>(
        this IServiceCollection services,
        ServiceLifetime lifetime)
        where TService : class
        where TImplementation : class, TService;

    public static IServiceCollection TryAddEnumerable(
        this IServiceCollection services,
        Type keyType,
        Type serviceType,
        Type implementationType,
        ServiceLifetime lifetime);
}

API Usage

Putting it all together, here's how the API can be leveraged for any container framework that supports registration through IServiceCollection.

public interface IThing
{
    string ToString();
}

public abstract class ThingBase : IThing
{
    protected ThingBase() { }
    public override string ToString() => GetType().Name;
}

public sealed class Thing : ThingBase { }

public sealed class KeyedThing : ThingBase { }

public sealed class Thing1 : ThingBase { }

public sealed class Thing2 : ThingBase { }

public sealed class Thing3 : ThingBase { }

public static class Key
{
    public sealed class Thingies { }
    public sealed class Thing1 { }
    public sealed class Thing2 { }
}

public class CatInTheHat
{
    public CatInTheHat(
        IKeyed<Key.Thing1, IThing> thing1,
        IKeyed<Key.Thing2, IThing> thing2)
    {
        Thing1 = thing1.Value;
        Thing2 = thing2.Value;
    }

    public IThing Thing1 { get; }
    public IThing Thing2 { get; }
}

public void ConfigureServices(IServiceCollection collection)
{
    // keyed types
    services.AddSingleton<Key.Thing1, IThing, Thing1>();
    services.AddTransient<Key.Thing2, IThing, Thing2>();

    // non-keyed type with keyed type dependencies
    services.AddSingleton<CatInTheHat>();

    // keyed open generics
    services.AddTransient(typeof(IGeneric<>), typeof(Generic<>));
    services.AddSingleton(typeof(IKeyed<,>), typeof(KeyedOpenGeneric<,>));

    // keyed IEnumerable<T>
    services.TryAddEnumerable<Key.Thingies, IThing, Thing1>(ServiceLifetime.Transient);
    services.TryAddEnumerable<Key.Thingies, IThing, Thing2>(ServiceLifetime.Transient);
    services.TryAddEnumerable<Key.Thingies, IThing, Thing3>(ServiceLifetime.Transient);

    var provider = services.BuildServiceProvider();

    // resolve non-keyed type with keyed type dependencies
    var catInTheHat = provider.GetRequiredService<CatInTheHat>();

    // resolve keyed, open generic
    var openGeneric = provider.GetRequiredService<Key.Thingy, IGeneric<object>>();

    // resolve keyed IEnumerable<T>
    var thingies = provider.GetServices<Key.Thingies, IThing>();

    // related services such as IServiceProviderIsService
    var query = provider.GetRequiredService<IServiceProviderIsService>();
    var thing1Registered = query.IsService<Key.Thing1, IThing>();
    var thing2Registered = query.IsService(typeof(Key.Thing2), typeof(IThing));
}

Container Integration

All of the well-known containers listed in the Microsoft.Extensions.DependencyInjection repository README.md are supported.

Container By Key By Key
(Generic)
Many
By Key
Many By
Key (Generic)
Open
Generics
Existing
Instance
Implementation
Factory
Default
Autofac
DryIoc
Grace
Lamar
LightInject
Stashbox
StructureMap
Unity
Container No Adatper
Changes
Example
Project
Autofac view
Default view
DryIoc view
Grace 1 view
Lamar view
LightInject view
Stashbox view
StructureMap view
Unity view

[1]: Only Implementation Factory doesn't work out-of-the-box

  • Just Works: Works without any changes or special adaptation to IServiceCollection
  • No Container Changes: Works without requiring fundamental container changes

About

A keyed services implementation for Microsoft.Extensions.DependencyInjection

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages