blog post image
Andrew Lock avatar

Andrew Lock

~11 min read

Keyed service dependency injection container support

Exploring the .NET 8 preview - Part 6

In this post I discuss the new "keyed service" support for the dependency injection container, introduced in .NET 8 preview 7. I describe how to use keyed services, when you might want to use them, and how they work behind the scenes.

As these posts are all using the preview builds, some of the features may change (or be removed) before .NET 8 finally ships in November 2023!

What are keyed services?

Dependency injection (DI) is everywhere in ASP.NET Core. You use it with your custom services, but perhaps more importantly, the framework itself uses DI throughout. Most everything that you can configure in ASP.NET Core is configured via DI.

Consequently, ASP.NET Core ships with a built-in DI container (also available as Microsoft.Extensions.DependencyInjection). This container is pretty basic in many ways. It's a conforming container that defines the minimum features a DI container must have. You can always add a third-party container like Lamar or Autofac but many people stick to the built-in container because, well, it's the default.

A reminder that my new book, ASP.NET Core in Action, Third Edition, covers dependency injection in detail, including how to use a third-party container. What's more, you can currently get 45% off at manning.com with the code pblock3 only until August 17th, so if you're considering purchasing it, now is the time to grab it!

When registering services with the built-in container, you can only control three things:

  • The Lifetime—this controls how often an instance of your service should be reused, and can be one of three values: Transient, Scoped, or Singleton.
  • The ServiceType—this is the type that you "request" in your constructors. It may be an interface, like IWidget, or a concrete type, like Widget.
  • The ImplementationType/instance—this is the type (or instance) that is used to satisfy the ServiceType dependency, for example Widget.

There are lots of helpers and overloads around these registrations, but fundamentally a service registration consisted of only these pieces of information, stored together as a ServiceDescriptor

theatrical pause…switch to dramatic movie voice…

Until now.

With keyed services, another piece of information is stored with the ServiceDescriptor, a ServiceKey that identifies the service. The key can be any object, but it will commonly be a string or an enum (something that can be a constant so it can be used in attributes). For non-keyed services, the ServiceType identifies the registration; for keyed services, the combination of ServiceType and ServiceKey identifies the registration.

This feature is new to the built-in DI container in .NET 8, but it's been available in other DI containers for a long time. Structuremap, for example, has an identical feature called "Named services", as does Autofac.

That's the simple explanation, so now let's look at a basic example of how to use keyed services.

Using keyed services to retrieve specific service instances

Before I show an example, I'll point out a current glaring limitation: neither minimal APIs nor MVC currently support keyed services directly. There are issues logged for these here and here, but the lack of support makes the following examples more convoluted than I'd have liked 😅

Keyed services are useful when you have an interface/service with multiple implementations that you want to use in your app. What's more, you need to use each of those implementations in different places in your app.

As an example, consider the following interface:

public interface INotificationService
{
    string Notify(string message);
}

And we have three simple placeholder implementations:

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}";
}

Without keyed services

Without keyed services, you could register all these services like this:

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

but then you could only retrieve all of the services like this:

public class NotifierService(IEnumerable<INotificationService> services){}

or you could retrieve only the last registered service (PushNotificationService) like this

public class NotifierService(INotificationService service){}

Previously, there wasn't a simple way to retrieve the SmsNotificationService or EmailNotificationService directly, while still registering them as INotificationService. With keyed services that becomes possible.

Note that there have always been workarounds for this, such as registering the service as a concrete type, and delegating the INotificationService registration. I describe this approach in a previous post, but it's always felt a bit like a hack.

With keyed services

To register a keyed service, use one of the AddKeyedSingleton(), AddKeyedScoped(), or AddKeyedTransient() overloads, and provide an object as a key. In the following example I used a string, but you may want to use an enum or shared constants, for example:

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

To retrieve a keyed service, apply the [FromKeyedServices(object key)] attribute to a parameter in your service's constructor. The following example demonstrates this using the C#12 feature, primary constructors:

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

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

We would then need to register the wrapper services, and could then use those in minimal APIs or MVC. The registration and usage would look something like this:

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.Sms("Hello world"));
app.MapGet("/email", (EmailWrapper notifier) => notifier.Email("Hello world"));

app.Run();

This is obviously a contrived example, but there are plenty of cases where you might want to do something similar. There were always ways around this by using a factory pattern or "double registration" as I mentioned previously, but some people may prefer the direct DI approach of keyed services.

Nevertheless, the current keyed services implementations has some limitations.

Limitations with the current implementation

I've already mentioned that the first limitation I ran into is that you currently can't use [FromKeyedServices] directly in minimal APIs like this:

                    // ⚠ don't do this, you'll get runtime errors
app.MapGet("/sms", ([FromKeyedServices("sms")] INotificationService service) 
                         => service.Notify("Hello world"));

Unfortunately, minimal APIs don't recognise the [FromKeyedServices] attribute and will attempt to bind your service to the request body instead. If you use it on a GET request, as in the above example, you'll get an exception at runtime like the following:

Example of the runtime error from missing keyed service

In another interesting point, if you enable the minimal API source generator it doesn't appear to intercept the method correctly🤷‍♂️

The good news is that support for this pattern is already being tracked, and is scheduled for inclusion in .NET 8 RC1. Whether it'll make that I guess we'll see, but it will almost certainly be in the final .NET 8 release at the latest.

A similar issue is tracking support for [FromKeyedServices] in MVC, which is also slated for inclusion in RC1. In the mean time, if you desperately want to use keyed services, you'll need to use the "wrapper" approach I showed in the previous section.

Another limitation I ran into is with the IKeyedServiceProvider demonstrated in the .NET 8 preview 7 announcement post. Long story short; the demonstrated code doesn't work when used with a scoped service provider:

// ⚠ Shows injecting an IKeyedServiceProvider, but this doesn't work in preview 7
class SmallCacheConsumer(IKeyedServiceProvider keyedServiceProvider)
{
     public object? GetData() 
          => keyedServiceProvider.GetRequiredKeyedService<IMemoryCache>("small");
}

The problem is that in the .NET 8 preview 7 release, IKeyedServiceProvider isn't registered with the DI container. What's more, when you request IServiceProvider from the DI container you typically get a scoped service provider, ServiceProviderEngineScope, which implements IServiceProvider but doesn't implement IKeyedServiceProvider in the preview 7 release. Which means you can't do something like this either:

// ⚠ This also doesn't work in preview 7
class SmallCacheConsumer(IServiceProvider serviceProvider)
{
     public object? GetData() 
          => serviceProvider.GetRequiredKeyedService<IMemoryCache>("small");
}

ServiceProviderEngineScope does implement IKeyedServiceProvider in the current nightly code), so I've no doubt this will be resolved in the next preview/RC release, but it's something else to be aware of in the mean time.

Interestingly, the ServiceProvider type already implements IKeyedServiceProvider, which means you can already resolve keyed services directly from the root IServiceProvider. For example:

var builder = WebApplication.CreateBuilder(args);
// Add keyed services
builder.Services.AddKeyedSingleton<INotificationService, SmsNotificationService>("sms");
builder.Services.AddKeyedSingleton<INotificationService, EmailNotificationService>("email");
var app = builder.Build();

// 👇 Resolving from the root container DOES work in preview 7
var smsService = app.Services.GetRequiredKeyedService<INotificationService>("sms");

Hopefully these issues should all be resolved with the final .NET 8 release, but bear them in mind if you're working with the previews.

Exploring the edge cases

In typical ASP.NET Core applications, the bulk of DI interactions are through composition of service constructors, so you'll likely use the [FromKeyedServices] as the main way to retrieve keyed services, as you saw earlier in this post. Nevertheless, there are a variety of nuances to how this all works. In this section I'll demonstrate some of those subtleties.

The easiest way to think about all this is to remember that keyed services work the same way as non-keyed services. The only difference is how services in the container are identified:

  • Keyed services use a ServiceType and a ServiceKey to define their uniqueness
  • Non-keyed services use a ServiceType to define their uniqueness. Alternatively, you can consider this to be the same as a keyed service where the ServiceKey is null

Once you realise the rules for non-keyed services and keyed services are essentially the same, the following edge cases all make sense!

Registering more than one service with the same key

The built-in DI container allows you to register the same service with multiple implementations:

builder.Services.AddSingleton<INotificationService, SmsNotificationService>();
builder.Services.AddSingleton<INotificationService, EmailNotificationService>();

In the same way, you can register a keyed services with the same key multiple times, for example:

builder.Services.AddKeyedSingleton<INotificationService, SmsNotificationService>("sms");
builder.Services.AddKeyedSingleton<INotificationService, EmailNotificationService>("sms");

This is perfectly valid, so make sure you watch out for bugs in this sort of thing!

Retrieving multiple services registered with the same key

If you intentionally register multiple services with the same key, you will likely want to retrieve those services. And, unsurprisingly, you can retrieve all the registered keyed services in the same was as non-keyed services.

For example, to retrieve all instances in a constructor inject an IEnumerable<T> and decorate with [FromKeyedServices]:

//  Add the keyed services attribute with the key 👇     👇 and inject as IEnumerable<>
public class NotifierService([FromKeyedServices("sms")] IEnumerable<INotificationService> smsServices)
{
}

If you inject a single instance of the INotificationService, then just like non-keyed services, the "last registered service" wins:

//  Add the keyed services attribute with the key 👇
public class NotifierService([FromKeyedServices("sms")] INotificationService smsService)
{
     // smsService is EmailNotificationService
}

Conditionally registering and removing keyed services

There are already many extension methods for IServiceCollection for registering services, and there are now a whole bunch of additional methods for conditionally registering keyed services (TryAdd*) and for removing services (RemoveAllKeyed). These are all pretty self explanatory, and are numerous mostly because there are duplicates for each lifetime:

 namespace Microsoft.Extensions.DependencyInjection.Extensions {
     public static class ServiceCollectionDescriptorExtensions {
+        public static IServiceCollection RemoveAllKeyed(this IServiceCollection collection, Type serviceType, object? serviceKey);
+        public static IServiceCollection RemoveAllKeyed<T>(this IServiceCollection collection, object? serviceKey);
+        public static void TryAddKeyedScoped(this IServiceCollection collection, Type service, object? serviceKey);
+        public static void TryAddKeyedScoped(this IServiceCollection collection, Type service, object? serviceKey, Func<IServiceProvider, object, object> implementationFactory);
+        public static void TryAddKeyedScoped(this IServiceCollection collection, Type service, object? serviceKey, Type implementationType);
+        public static void TryAddKeyedScoped<TService, TImplementation>(this IServiceCollection collection, object? serviceKey) where TService : class where TImplementation : class, TService;
+        public static void TryAddKeyedScoped<TService>(this IServiceCollection collection, object? serviceKey) where TService : class;
+        public static void TryAddKeyedScoped<TService>(this IServiceCollection services, object? serviceKey, Func<IServiceProvider, object, TService> implementationFactory) where TService : class;
+        public static void TryAddKeyedSingleton(this IServiceCollection collection, Type service, object? serviceKey);
+        public static void TryAddKeyedSingleton(this IServiceCollection collection, Type service, object? serviceKey, Func<IServiceProvider, object, object> implementationFactory);
+        public static void TryAddKeyedSingleton(this IServiceCollection collection, Type service, object? serviceKey, Type implementationType);
+        public static void TryAddKeyedSingleton<TService, TImplementation>(this IServiceCollection collection, object? serviceKey) where TService : class where TImplementation : class, TService;
+        public static void TryAddKeyedSingleton<TService>(this IServiceCollection collection, object? serviceKey) where TService : class;
+        public static void TryAddKeyedSingleton<TService>(this IServiceCollection services, object? serviceKey, Func<IServiceProvider, object, TService> implementationFactory) where TService : class;
+        public static void TryAddKeyedSingleton<TService>(this IServiceCollection collection, object? serviceKey, TService instance) where TService : class;
+        public static void TryAddKeyedTransient(this IServiceCollection collection, Type service, object? serviceKey);
+        public static void TryAddKeyedTransient(this IServiceCollection collection, Type service, object? serviceKey, Func<IServiceProvider, object, object> implementationFactory);
+        public static void TryAddKeyedTransient(this IServiceCollection collection, Type service, object? serviceKey, Type implementationType);
+        public static void TryAddKeyedTransient<TService, TImplementation>(this IServiceCollection collection, object? serviceKey) where TService : class where TImplementation : class, TService;
+        public static void TryAddKeyedTransient<TService>(this IServiceCollection collection, object? serviceKey) where TService : class;
+        public static void TryAddKeyedTransient<TService>(this IServiceCollection services, object? serviceKey, Func<IServiceProvider, object, TService> implementationFactory) where TService : class;
     }
 }

And that's about all there is to it. Keyed services simplify some service composition requirements so you may find them useful if you're currently using one of the DI "workarounds". But keyed services aren't strictly necessary, so don't feel like you need to use them just because they're there! 🙂

Summary

In this post I described the new keyed service feature added to the built-in dependency injection container that was released in .NET 8 preview 7. You can add a service as a "keyed" service using AddKeyedSingleton<T>(object? key) (and similarly for transient or scoped services). To inject a keyed service into a constructor, decorate a parameter with the [FromKeyedServices(object key)] attribute, passing in the same key used to register the service. You can also use the IKeyedServiceProvider as a service locator to retrieve keyed services.

The current implementation has some limitations: you can't use [FromKeyedServices] in minimal APIs or MVC/Razor Pages currently, and you can't inject IKeyedServiceProvider into service constructors yet. These limitations should all be addressed by the time .NET 8 is released in November.

Andrew Lock | .Net Escapades
Want an email when
there's new posts?