Dependency Injection (DI) is a cornerstone of modern .NET applications, keeping our code clean, testable and easy to maintain.
However, until recently, DI in .NET had one common limitation and that is handling multiple implementations of the same interface.
That’s where Keyed Services come in, a DI feature introduced in .NET 8.
Keysed Services
Keyed Services allows you to register multiple implementations of the same interface, each identified by a key.
This means you can easily inject different implementations of the same interface, based on context, without any custom factory logic.
This key can be a string, an enum or any other comparable type.
Getting Started
For this example, here's an simple example:
public interface INotificationService
{
void Send(string message);
}
public class EmailNotificationService : INotificationService
{
public void Send(string message) => Console.WriteLine($"Email: {message}");
}
public class SmsNotificationService : INotificationService
{
public void Send(string message) => Console.WriteLine($"SMS: {message}");
}
Before Keyed Services
Before .NET 8, the built-in dependency injection system didn’t have a straightforward way to register and resolve multiple implementations of the same interface.
If you tried to register both in the container like this:
builder.Services.AddTransient<INotificationService, EmailNotificationService>();
builder.Services.AddTransient<INotificationService, SmsNotificationService>();
Only the last registered service (SmsNotificationService) would actually be used, because each registration overwrote the previous one.
To make both available, we had to rely on workarounds, such as resolving them with factories or filtering through IEnumerable<T>:
public class NotificationProcessor(IEnumerable<INotificationService> services)
{
public void Process()
{
foreach (var service in services)
{
service.Send("Processing notification...");
}
}
}
In short, it was cumbersome and harder to work with.
With Keyed Services
Now, it's as easy as it can be:
builder.Services.AddKeyedTransient<INotificationService, EmailNotificationService>("email");
builder.Services.AddKeyedTransient<INotificationService, SmsNotificationService>("sms");
.NET 8 introduced AddKeyedTransient, AddKeyedScoped and AddKeyedSingleton for all three lifetimes.
With implementations you just need to add unique keys which can be strings or enums. I personally prefer enums better.
You can inject a concrete implementation like this:
public class NotificationProcessor(
[FromKeyedServices("email")] INotificationService emailService,
[FromKeyedServices("sms")] INotificationService smsService)
{
public void Process()
{
emailService.Send("Processing email notification...");
smsService.Send("Processing SMS notification...");
}
}
By using FromKeyedServices attribute and specificying the key, DI will resolve the service accordingly.
If you need to resolve a service dynamically in runtime you can use GetRequiredKeyedService:
var emailService = serviceProvider.GetRequiredKeyedService<INotificationService>("email");
emailService.Send("Sending via Email Service");
Conclusion
Keyed Services make the built-in .NET DI container far more flexible when working with multiple implementations of the same interface.
Instead of writing custom factories or manual filtering logic, you can now select the exact implementation you need using a simple, explicit key.
If you want to check out examples I created, you can find the source code here:
Source CodeI hope you enjoyed it, subscribe and get a notification when a new blog is up!
