Keyed service dependency injection container support: Exploring the .NET 8 preview – Part 6

Dependency injection in ASP.NET Core has always had one gap that shows up the moment you have more than one implementation of the same interface. Say you have three notification channels, SMS, email and push, and you want to inject exactly the one you need in a specific class. Until .NET 8, the built-in DI container gave you no clean way to do that. .NET 8 preview 7 closes this gap with a new feature called keyed services, and this post walks through how it works, where it still falls short in the preview build, and where it genuinely helps in real applications.

What keyed services actually solve

A DI registration in the built-in container has always consisted of three pieces: the lifetime (transient, scoped or singleton), the service type you ask for in a constructor, and the implementation type or instance that satisfies it. Everything else is a variation on top of this. Third-party containers like Autofac and Lamar have offered named or keyed registrations for years. The built-in Microsoft.Extensions.DependencyInjection container never did, mainly because it is deliberately a conforming container, meaning it implements only the baseline feature set that every DI container in the .NET ecosystem is expected to support.

Keyed services change that baseline slightly. Each ServiceDescriptor can now carry an extra ServiceKey alongside the ServiceType. For a normal registration, the ServiceType alone identifies the entry in the container. For a keyed registration, the combination of ServiceType and ServiceKey identifies it. The key itself can be any object, though in practice you will almost always use a string or an enum, since both work well as attribute arguments and as constants.

The problem without keyed services

Take an interface with three implementations, one for each notification channel:

public interface INotificationService
{
    string Notify(string message);
}
public class SmsNotificationService : INotificationService
{
    public string Notify(string message) => $"[SMS] {message}";
}
 
public class EmailNotificationService : INotificationService
{
    public string Notify(string message) => $"[Email] {message}";
}
 
public class PushNotificationService : INotificationService
{
    public string Notify(string message) => $"[Push] {message}";
}

If you register all three against INotificationService the usual way:

var builder = WebApplication.CreateBuilder(args);
 
builder.Services.AddSingleton<INotificationService, SmsNotificationService>();
builder.Services.AddSingleton<INotificationService, EmailNotificationService>();
builder.Services.AddSingleton<INotificationService, PushNotificationService>();

you are left with two options when consuming the service. You can inject IEnumerable<INotificationService> and get all three, or you can inject INotificationService directly and get only the last one registered, which in this case is PushNotificationService.

// Gets all three implementations
public class NotifierService(IEnumerable<INotificationService> services)
{
}
// Gets only PushNotificationService, the last one registered
public class NotifierService(INotificationService service)
{
}

There is no built-in way to say, give me specifically the SMS implementation and nothing else. Developers have worked around this for years by registering each implementation under its own concrete type and manually delegating the interface registration to one of them. It works, but it always felt like a workaround bolted on top of the container rather than a feature the container actually supported.

Registering and resolving keyed services

With keyed services you register each implementation against a key using AddKeyedSingleton, AddKeyedScoped or AddKeyedTransient. The key goes in as the last argument:

var builder = WebApplication.CreateBuilder(args);
 
builder.Services.AddKeyedSingleton<INotificationService, SmsNotificationService>("sms");
builder.Services.AddKeyedSingleton<INotificationService, EmailNotificationService>("email");
builder.Services.AddKeyedSingleton<INotificationService, PushNotificationService>("push");

To pull out a specific implementation, decorate a constructor parameter with [FromKeyedServices(key)], passing the same key you used at registration. Combined with the C# 12 primary constructor syntax, this reads quite cleanly:

// Uses the key "sms" to resolve SmsNotificationService specifically
public class SmsWrapper([FromKeyedServices("sms")] INotificationService sms)
{
    public string Notify(string message) => sms.Notify(message);
}
 
// Uses the key "email" to resolve EmailNotificationService specifically
public class EmailWrapper([FromKeyedServices("email")] INotificationService email)
{
    public string Notify(string message) => email.Notify(message);
}

You then register the wrapper classes as regular singletons and consume them from minimal APIs or MVC as usual:

var builder = WebApplication.CreateBuilder(args);
 
builder.Services.AddKeyedSingleton<INotificationService, SmsNotificationService>("sms");
builder.Services.AddKeyedSingleton<INotificationService, EmailNotificationService>("email");
builder.Services.AddKeyedSingleton<INotificationService, PushNotificationService>("push");
builder.Services.AddSingleton<SmsWrapper>();
builder.Services.AddSingleton<EmailWrapper>();
 
var app = builder.Build();
 
app.MapGet("/sms", (SmsWrapper notifier) => notifier.Notify("Hello world"));
app.MapGet("/email", (EmailWrapper notifier) => notifier.Notify("Hello world"));
 
app.Run();

Hitting /sms returns [SMS] Hello world and /email returns [Email] Hello world, each one routed through its own wrapper to the correct implementation. Notice that you still need this wrapper class in the current preview build. Minimal APIs cannot resolve keyed services directly inside a route handler yet, which is the first limitation worth knowing about before you plan around this feature.

Limitations in the preview 7 implementation

The feature is genuinely useful, but if you try to use it in preview 7 you run into a few rough edges fairly quickly. Keep these in mind before you plan production adoption around the preview builds, since some of this is expected to change before the final .NET 8 release in November 2023.

Minimal APIs and MVC do not understand FromKeyedServices yet

You cannot use the attribute directly on a minimal API route parameter:

// Do not do this, you will get a runtime error
app.MapGet("/sms", ([FromKeyedServices("sms")] INotificationService service)
    => service.Notify("Hello world"));

Minimal APIs do not recognise [FromKeyedServices] and instead try to bind the parameter from the request body. Send a GET request against this route and you get a runtime exception like the one shown below.

Runtime error thrown when FromKeyedServices is used directly in a minimal API route handler
Runtime error thrown when FromKeyedServices is used directly in a minimal API route handler

The fix is tracked for .NET 8 RC1 and is already scheduled. A similar fix is tracked separately for MVC and Razor Pages. Until either lands, the wrapper class approach shown earlier is the only reliable way to get a keyed service into a minimal API or MVC action.

IKeyedServiceProvider and scoped resolution

The second rough edge involves IKeyedServiceProvider. In theory you should be able to inject IKeyedServiceProvider, or even plain IServiceProvider, into any class and call GetRequiredKeyedService<T>(key) on it directly:

// Does not work in preview 7: IKeyedServiceProvider is not registered
class SmallCacheConsumer(IKeyedServiceProvider keyedServiceProvider)
{
    public object? GetData()
        => keyedServiceProvider.GetRequiredKeyedService<IMemoryCache>("small");
}
 
// Also fails in preview 7, for the same underlying reason
class SmallCacheConsumer(IServiceProvider serviceProvider)
{
    public object? GetData()
        => serviceProvider.GetRequiredKeyedService<IMemoryCache>("small");
}

Neither of these works in preview 7. IKeyedServiceProvider is not registered with the container as its own service, and the scoped provider you normally get injected, ServiceProviderEngineScope, does not implement IKeyedServiceProvider in this build. Interestingly, resolving keyed services directly from the root IServiceProvider does work, because the root ServiceProvider type already implements the interface:

var builder = WebApplication.CreateBuilder(args);
 
builder.Services.AddKeyedSingleton<INotificationService, SmsNotificationService>("sms");
builder.Services.AddKeyedSingleton<INotificationService, EmailNotificationService>("email");
 
var app = builder.Build();
 
// Resolving from the root container works, even in preview 7
var smsService = app.Services.GetRequiredKeyedService<INotificationService>("sms");

This inconsistency is already fixed in the nightly builds at the time of writing, so treat it as a preview-only quirk rather than a deliberate design decision. Still, it is worth knowing about if you hit a confusing exception while experimenting with the preview and cannot figure out why root resolution works but constructor injection does not.

Edge cases that follow from how the container works

Once you accept that keyed services behave exactly like non-keyed services, just with an extra identifier attached, most of the edge cases stop being surprising. A non-keyed registration is effectively a keyed registration where the key happens to be null, and the container treats both cases with the same underlying logic.

Registering the same key more than once

Just as the container happily accepts multiple registrations of INotificationService without a key, it accepts multiple registrations under the same key:

builder.Services.AddKeyedSingleton<INotificationService, SmsNotificationService>("sms");
builder.Services.AddKeyedSingleton<INotificationService, EmailNotificationService>("sms");
// Both are now registered under the key "sms", and this compiles and runs fine

This is perfectly valid as far as the container is concerned. If you are not careful about which implementations get registered under which key, especially across multiple registration modules or extension methods, this is an easy source of bugs that will not surface until you inspect what actually gets resolved at runtime.

Resolving multiple services sharing a key

You can retrieve every implementation registered under a given key using IEnumerable<T>, exactly as you would for non-keyed services:

public class NotifierService(
    [FromKeyedServices("sms")] IEnumerable<INotificationService> smsServices)
{
}

and if you inject a single instance instead, the last one registered under that key wins, following the same rule as non-keyed registrations:

public class NotifierService(
    [FromKeyedServices("sms")] INotificationService smsService)
{
    // smsService resolves to EmailNotificationService, since it was
    // registered last under the key "sms" in the example above
}

Conditional registration and removal

The DI extension methods also gained keyed equivalents of the familiar TryAdd and RemoveAll helpers, namely TryAddKeyedSingleton, TryAddKeyedScoped, TryAddKeyedTransient and RemoveAllKeyed, each following the same overload patterns you already know from the non-keyed versions. There is nothing conceptually new here. It is the existing API surface extended to accept a key parameter, so if you already know how TryAddSingleton or RemoveAll work, these behave identically.

Should you actually use keyed services

Keyed services are a solid addition for scenarios where you genuinely need to pick between multiple implementations at the injection point, and where a factory or strategy pattern feels heavier than it needs to be. Multi-tenant systems that switch between providers per tenant, or applications wiring up multiple Azure OpenAI deployments behind the same interface, are good real-world candidates once the minimal API and MVC gaps close in RC1.

That said, do not reach for keyed services just because they are new. If your app only ever needs one implementation of an interface at a time, or if the selection logic is complex enough to warrant a factory with its own decision rules, plain DI or a factory pattern will likely stay easier to read and test. Keyed services solve a narrow problem well, but they are not a general replacement for good composition design. Given this is still preview 7 behaviour, expect the rough edges around minimal APIs, MVC and IKeyedServiceProvider to be resolved before .NET 8 ships in November 2023.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading