DEV Community

Raffael Eloi
Raffael Eloi

Posted on

Stop Using Switch Statements: Keyed Services in .NET - A Practical Approach

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
Enter fullscreen mode Exit fullscreen mode

Here's an interface that represents that:

internal interface IEventHandlerFactory
{
    Task HandleEvent(string eventName, object eventData);
}
Enter fullscreen mode Exit fullscreen mode

Each event handler should implement this interface

internal interface IEventHandler
{
    Task HandleAsync(object eventData, CancellationToken cancellationToken);
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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}");
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

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)