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.
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.
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);
}
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));
}
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