You have a single interface, three implementations, and a constructor that needs exactly the right one. Before .NET 8, solving this meant either injecting IEnumerable<T> and filtering, writing a custom factory, or reaching for a third-party container. None of these were particularly elegant, and all of them leaked selection logic into business code that shouldn't care about wiring.
Keyed services, introduced in .NET 8 and refined through .NET 10, bring named registrations to the built-in dependency injection container. You tag each registration with a key, then resolve by that key at the injection site. No factories, no filtering, no container swap required.
The problem keyed services solve
Consider a notification system. You have email, SMS, and push notification channels, all behind the same INotificationService interface. Without keyed services, the standard DI container gives you two options:
// Option 1: Inject all implementations and filter
public class OrderProcessor(IEnumerable<INotificationService> services)
{
public async Task NotifyAsync(Order order)
{
// Bad — the consumer has to know about concrete types
var email = services.OfType<EmailNotificationService>().First();
await email.SendAsync(order.CustomerEmail, "Order confirmed");
}
}
// Option 2: Build a factory that the container doesn't manage
public class NotificationFactory(IServiceProvider provider)
{
public INotificationService Create(string channel) => channel switch
{
"email" => provider.GetRequiredService<EmailNotificationService>(),
"sms" => provider.GetRequiredService<SmsNotificationService>(),
_ => throw new ArgumentException($"Unknown channel: {channel}")
};
}
Both approaches work, but they push service selection into runtime code. The factory approach is essentially a hand-rolled service locator, which is precisely what DI is meant to replace.
Registering keyed services
Registration mirrors the standard DI methods, with an additional key parameter:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddKeyedScoped<INotificationService, EmailNotificationService>("email");
builder.Services.AddKeyedScoped<INotificationService, SmsNotificationService>("sms");
builder.Services.AddKeyedScoped<INotificationService, PushNotificationService>("push");
The key is typed as object, so you can use strings, enums, integers, or any type that correctly implements Equals and GetHashCode. Each lifetime variant has a keyed counterpart:
AddKeyedSingleton<TService, TImplementation>(object key)AddKeyedScoped<TService, TImplementation>(object key)AddKeyedTransient<TService, TImplementation>(object key)
Factory overloads are available too, which is useful when the implementation needs constructor arguments that aren't registered in the container:
builder.Services.AddKeyedSingleton<ICache>("redis", (sp, key) =>
{
var config = sp.GetRequiredService<IConfiguration>();
return new RedisCache(config.GetConnectionString("Redis")!);
});
Resolving with [FromKeyedServices]
The [FromKeyedServices] attribute is how you tell the container which keyed registration to inject. It works in constructor parameters across the entire ASP.NET Core stack.
Minimal APIs
app.MapPost("/orders/{id}/notify", async (
int id,
[FromKeyedServices("email")] INotificationService notifier,
OrderDbContext db) =>
{
var order = await db.Orders.FindAsync(id);
if (order is null) return Results.NotFound();
await notifier.SendAsync(order.CustomerEmail, "Your order has shipped.");
return Results.Ok();
});
Controllers
[ApiController]
[Route("api/[controller]")]
public class NotificationController(
[FromKeyedServices("sms")] INotificationService smsNotifier) : ControllerBase
{
[HttpPost("sms")]
public async Task<IActionResult> SendSms(SmsRequest request)
{
await smsNotifier.SendAsync(request.PhoneNumber, request.Message);
return Ok();
}
}
Middleware
Middleware supports keyed services in both the constructor and the Invoke/InvokeAsync method. Singleton and keyed-singleton services can be injected via the constructor; scoped and transient services must go through the method parameters:
public class AuditMiddleware(
RequestDelegate next,
[FromKeyedServices("audit")] INotificationService auditNotifier)
{
public async Task InvokeAsync(HttpContext context)
{
await next(context);
if (context.Response.StatusCode >= 500)
{
await auditNotifier.SendAsync("[email protected]",
$"500 error on {context.Request.Path}");
}
}
}
SignalR hubs
SignalR hubs support [FromKeyedServices] in both the constructor and hub method parameters:
public class DashboardHub([FromKeyedServices("push")] INotificationService pushNotifier) : Hub
{
public async Task BroadcastAlert(string message)
{
await pushNotifier.SendAsync("all", message);
await Clients.All.SendAsync("AlertReceived", message);
}
}
Blazor components
In Razor components, use the Key property on the [Inject] attribute:
@page "/notifications"
@code {
[Inject(Key = "email")]
public INotificationService? EmailNotifier { get; set; }
}
Choosing the right key type
String keys
Strings are the most common choice. They're simple and readable, but they're prone to typos that the compiler won't catch:
// This compiles fine but fails at runtime
public class Broken([FromKeyedServices("emial")] INotificationService notifier) { }
If you use string keys, define them as constants:
public static class ServiceKeys
{
public const string Email = "email";
public const string Sms = "sms";
public const string Push = "push";
}
builder.Services.AddKeyedScoped<INotificationService, EmailNotificationService>(ServiceKeys.Email);
// ...
public class OrderProcessor([FromKeyedServices(ServiceKeys.Email)] INotificationService notifier) { }
Enum keys
Enums give you IntelliSense, compile-time checking, and a natural place to document the set of valid options:
public enum NotificationChannel { Email, Sms, Push }
builder.Services.AddKeyedScoped<INotificationService, EmailNotificationService>(NotificationChannel.Email);
builder.Services.AddKeyedScoped<INotificationService, SmsNotificationService>(NotificationChannel.Sms);
builder.Services.AddKeyedScoped<INotificationService, PushNotificationService>(NotificationChannel.Push);
The downside is that enums are boxed when used as object keys, though this is negligible for registration and resolution — it happens once per request at most, not in a hot loop.
The AnyKey fallback
KeyedService.AnyKey lets you register a fallback that matches any key without an explicit registration. This is useful for providing a default implementation:
builder.Services.AddKeyedSingleton<ICache>("premium", new PremiumCache());
// Fallback for any other key
builder.Services.AddKeyedSingleton<ICache>(KeyedService.AnyKey, (sp, key) =>
new StandardCache(key?.ToString() ?? "default"));
With this registration, resolving ICache with the key "premium" returns PremiumCache. Any other key — "basic", "standard", "anything" — falls through to the AnyKey factory and returns a StandardCache instance.
You can also use AnyKey with GetKeyedServices<T> to enumerate all explicitly keyed registrations (those registered with a specific key, not the AnyKey fallback itself):
var allCaches = provider.GetKeyedServices<ICache>(KeyedService.AnyKey);
// WARNING
In .NET 10, calling GetKeyedService<T>() (singular) with KeyedService.AnyKey throws an InvalidOperationException. Use GetKeyedServices<T>() (plural) or resolve with a specific key instead.
Mixing keyed and non-keyed registrations
Keyed and non-keyed registrations of the same interface coexist without interference. A non-keyed registration resolves when no key is specified; a keyed registration resolves only when the matching key is provided:
builder.Services.AddScoped<INotificationService, EmailNotificationService>(); // default
builder.Services.AddKeyedScoped<INotificationService, SmsNotificationService>("sms");
// Resolves EmailNotificationService (non-keyed default)
public class DefaultConsumer(INotificationService notifier) { }
// Resolves SmsNotificationService (keyed)
public class SmsConsumer([FromKeyedServices("sms")] INotificationService notifier) { }
This is useful during migrations where you're gradually converting to keyed registrations, or when one implementation is the clear default and others are specialised variants.
Runtime resolution
When the key isn't known at compile time — for example, it comes from a configuration value, a database row, or a request header — use IServiceProvider to resolve dynamically:
public class NotificationDispatcher(IServiceProvider provider)
{
public async Task DispatchAsync(string channel, string recipient, string message)
{
var notifier = provider.GetRequiredKeyedService<INotificationService>(channel);
await notifier.SendAsync(recipient, message);
}
}
This is a legitimate use of the service locator pattern — the key is genuinely dynamic. The important distinction is that the selection logic lives in one place (the dispatcher), not scattered across consumers.
Common pitfalls
Forgetting that keyed services need [FromKeyedServices]
If you register a service with a key but inject it without the attribute, the container won't find it:
builder.Services.AddKeyedScoped<INotificationService, EmailNotificationService>("email");
// Bad — this resolves a non-keyed INotificationService, which doesn't exist
public class Broken(INotificationService notifier) { }
This fails at runtime with an InvalidOperationException. There's no compiler warning to catch it.
Using mutable objects as keys
The key is stored and compared using Equals. If you use a mutable object as a key and then modify it, lookups break silently. Stick to immutable types: strings, enums, integers, or record types.
Overusing dynamic resolution
If every consumer resolves keyed services via IServiceProvider.GetRequiredKeyedService<T>(), you've replaced one service locator with another. Use [FromKeyedServices] for static keys and reserve IServiceProvider for genuinely dynamic scenarios.
Confusing AnyKey with wildcard resolution
KeyedService.AnyKey is a fallback registration, not a wildcard resolver. In .NET 10, calling GetKeyedService<T>(KeyedService.AnyKey) throws an exception. Use GetKeyedServices<T>(KeyedService.AnyKey) if you need to enumerate all keyed registrations, or resolve with a specific key.
Scope mismatches with keyed singletons
The same scoping rules apply to keyed services as to non-keyed ones. A keyed singleton that depends on a scoped service triggers a scope validation error in development. Don't assume keyed services are exempt from lifetime rules.
Testing with keyed services
Keyed services integrate naturally with WebApplicationFactory for integration tests. Override specific keyed registrations without affecting the rest:
public class NotificationTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public NotificationTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.AddKeyedScoped<INotificationService, FakeEmailService>("email");
});
});
}
[Fact]
public async Task OrderNotification_UsesEmailChannel()
{
var client = _factory.CreateClient();
var response = await client.PostAsync("/orders/1/notify", null);
response.EnsureSuccessStatusCode();
}
}
For unit tests, inject the keyed service directly since the attribute is just metadata — the container does the wiring:
[Fact]
public async Task Dispatcher_SendsToCorrectChannel()
{
var fake = new FakeNotificationService();
var services = new ServiceCollection();
services.AddKeyedSingleton<INotificationService>("email", fake);
var provider = services.BuildServiceProvider();
var dispatcher = new NotificationDispatcher(provider);
await dispatcher.DispatchAsync("email", "[email protected]", "Hello");
Assert.Single(fake.SentMessages);
}
Summary
- Keyed services add named registrations to the built-in DI container, eliminating the need for
IEnumerable<T>filtering or custom factories. - Use
AddKeyedSingleton,AddKeyedScoped, orAddKeyedTransientto register, and[FromKeyedServices]to resolve. - They work across the entire ASP.NET Core stack: minimal APIs, controllers, middleware, SignalR hubs, and Blazor components.
- String constants or enums make the best keys — avoid magic strings and mutable objects.
KeyedService.AnyKeyprovides fallback registrations but has breaking changes in .NET 10 for singular resolution.- Reserve
IServiceProvider.GetRequiredKeyedService<T>()for genuinely dynamic keys; prefer the attribute for everything else. - Standard lifetime rules still apply — keyed services don't get special treatment for scoping or disposal.