Have you heard of Keyed Services? Do you know when to use them? Have you ever needed multiple implementations of the same interface and ended up writing a big switch or if-else block to decide which one to use?
Today, I'll show you a new tool for your software engineering toolbox and a hands-on approach using a POC project on my GitHub, where you can see the full implementation.
In this article, I'll show you how Keyed Services work and how they can simplify event-driven designs using a practical example.
What are Keyed Services?
Before .NET 8, registering multiple implementations of the same interface was possible, but resolving a specific implementation was not straightforward.
Typically, you would inject IEnumerable and manually choose the correct implementation using conditions or metadata.
But with Keyed Services, a built-in dependency injection feature introduced in .NET 8, you can register multiple classes for the same interface using a key.
Why is that important?
There are scenarios where we can reduce the number of if-else statements and achieve a cleaner solution using these keys.
Especially when handling events, we can build a solid implementation that is easy to extend using Keyed Services.
When should you use Keyed Services?
Keyed Services are useful when:
- You have multiple implementations of the same interface
- The correct implementation depends on runtime data
- You want to avoid switch / if-else logic
- You want a solution that is easy to extend
Common scenarios include:
- Event handlers
- Notification channels (Email, SMS)
How does it work?
Bear with me for this example:
I have an EventHandlerFactory that receives an event and then redirects it to the responsible handler.
Incoming Event
│
▼
EventHandlerFactory
│
▼
Keyed DI Resolution
│
▼
Specific EventHandler
Here's an interface that represents that:
internal interface IEventHandlerFactory
{
Task HandleEvent(string eventName, object eventData);
}
Each event handler should implement this interface
internal interface IEventHandler
{
Task HandleAsync(object eventData, CancellationToken cancellationToken);
}
Now let's create three event handlers for our mock scenario: PurchaseOrderEventHandler, InvoiceEventHandler, and PaymentEventHandler.
Each handler will look like this:
internal class PaymentEventHandler : IEventHandler
{
public Task HandleAsync(object eventData, CancellationToken cancellationToken)
{
// Implement the logic to handle payment events here
return Task.CompletedTask;
}
}
Now, here's the first implemenation of the EventHandlerFactory :
internal class EventHandlerFactory(IServiceProvider serviceProvider) : IEventHandlerFactory
{
public const string PurchaseOrder = "PurchaseOrder";
public const string PaymentProcessed = "PaymentProcessed";
public const string InvoiceProcessed = "InvoiceProcessed";
private readonly IServiceProvider _serviceProvider = serviceProvider;
public async Task HandleEvent(string eventName, object eventData)
{
var cancellationToken = new CancellationToken();
if (eventName == PurchaseOrder)
{
IEventHandler handler = _serviceProvider.GetRequiredKeyedService<IEventHandler>(PurchaseOrder);
await handler.HandleAsync(eventData, cancellationToken);
return;
}
else if (eventName == PaymentProcessed)
{
IEventHandler handler = _serviceProvider.GetRequiredKeyedService<IEventHandler>(PaymentProcessed);
await handler.HandleAsync(eventData, cancellationToken);
return;
}
else if (eventName == InvoiceProcessed)
{
IEventHandler handler = _serviceProvider.GetRequiredKeyedService<IEventHandler>(InvoiceProcessed);
await handler.HandleAsync(eventData, cancellationToken);
return;
}
throw new InvalidOperationException($"No handler found for event: {eventName}");
}
}
For dependency injection, the only thing we need to do is pass the key when registering the dependency:
serviceCollection.AddKeyedScoped<IEventHandler, PurchaseOrderEventHandler>(EventHandlerFactory.PurchaseOrder);
serviceCollection.AddKeyedScoped<IEventHandler, PaymentEventHandler>(EventHandlerFactory.PaymentProcessed);
serviceCollection.AddKeyedScoped<IEventHandler, InvoiceEventHandler>(EventHandlerFactory.InvoiceProcessed);
This is a good implementation. We can clearly see which event handler is responsible for each eventName. However, we can make it even better:
internal class EventHandlerFactory(IKeyedServiceProvider serviceProvider) : IEventHandlerFactory
{
public async Task HandleEvent(string eventName, object eventData)
{
var cancellationToken = new CancellationToken();
IEventHandler handler = serviceProvider.GetRequiredKeyedService<IEventHandler>(eventName);
await handler.HandleAsync(eventData, cancellationToken);
}
}
Final thoughts
I hope you learned something new today. This concept was pretty new to me until a couple of weeks ago.
Here's the repository with the full implementation of the POC scenario:
https://github.com/Raffael-Eloi/keyed-services-poc
Also, feel free to follow me on GitHub and LinkedIn:
- Github: https://github.com/Raffael-Eloi
- Linkedin: https://linkedin.com/in/raffael-eloi
- My site: https://raffaeleloi.dev
If you enjoy topics like this, follow me here or connect with me on LinkedIn. I regularly share content about .NET, software architecture, and engineering practices.
Top comments (0)