
When building ASP.NET Core applications, it is common practice to implement multiple classes that share a common interface, particularly when working with design patterns such as Chain of Responsibility or when dispatching work to a set of discrete handlers. A typical example of this might be a collection of INotificationHandler, ICommandHandler, or IEventHandler implementations that each deal with a specific concern.
Rather than manually registering every handler in Program.cs, which becomes increasingly tedious as the number of handlers grows, we can leverage Reflection to discover and register all implementations automatically, and then inject the full collection of handlers wherever they are needed.
In this post, I’ll walk through how to set this up in a clean and maintainable way.
Setup
Please note the assumed environment setup before proceeding.
The examples in this post target .NET 8 or later within a standard ASP.NET Core web application. No third-party libraries are required; everything is achievable using the built-in dependency injection container.
Aside from having an IDE such as Visual Studio or Visual Studio Code installed on your machine, all you need to do is start with an empty ASP.NET Core project and then add the code presented in the sections below to follow along.
Handlers
We’ll begin by defining the handler interfaces and implementations that will be used for the demonstration.
In the example scenario, each handler will receive a notification string and process it.
Interfaces
Let’s create a simple INotificationHandler interface to start with.
namespace HandlerDemo; public interface INotificationHandler {     Task HandleAsync(string notification); }
Note that you’ll need to adjust the namespace used in the code samples to suit your project.
Classes
Next, let’s create a couple of concrete handler implementations.
using System.Diagnostics; namespace HandlerDemo; public class EmailNotificationHandler : INotificationHandler {     public Task HandleAsync(string notification)     {         Debug.WriteLine("[Email] Sending notification: {0}", args: notification);         return Task.CompletedTask;     } }
The first implementation simulates the handling of an email notification.
using System.Diagnostics; namespace HandlerDemo; public class SmsNotificationHandler : INotificationHandler {     public Task HandleAsync(string notification)     {         Debug.WriteLine("[SMS] Sending notification: {0}", args: notification);         return Task.CompletedTask;     } }
The second implementation simulates the handling of an SMS notification.
Handler Registration
With both handlers in place, we could register them manually in Program.cs as shown below.
builder.Services.AddTransient<INotificationHandler, EmailNotificationHandler>(); builder.Services.AddTransient<INotificationHandler, SmsNotificationHandler>();
While this works perfectly fine for a small number of handlers, it doesn’t scale very well. Every time a new handler is added, we must remember to register it in Program.cs.
Instead, it’s possible to automate this entirely, meaning that when we require new behaviour, we can simply add a new class that implements the interface without modifying any other code files.
Auto-Registering Handlers
The idea here is straightforward: at startup, we scan the current assembly for all non-abstract classes that implement INotificationHandler, and register each one against the interface automatically.
The following extension method encapsulates this logic neatly.
using System.Reflection; namespace HandlerDemo; public static class ServiceCollectionExtensions {     public static IServiceCollection AddHandlers<TInterface>(         this IServiceCollection services,         Assembly? assembly = null,         ServiceLifetime lifetime = ServiceLifetime.Transient)         where TInterface : class     {         assembly ??= Assembly.GetCallingAssembly();         var handlerType = typeof(TInterface);         var implementations = assembly             .GetTypes()             .Where(t => t.IsClass && !t.IsAbstract && handlerType.IsAssignableFrom(t));         foreach (var implementation in implementations)         {             services.Add(new ServiceDescriptor(handlerType, implementation, lifetime));         }         return services;     } }
Let’s break down what the above method is doing.
- The method is generic, accepting any interface type via the
TInterfacetype parameter. - If no assembly is provided, the code defaults to scanning the calling assembly, which covers the most common use case.
- The
ServiceLifetimeparameter provides callers with full control over how the handlers are scoped (Transient,Scoped, orSingleton). - The
GetTypesmethod is used to enumerate all types in the assembly, filtering the results down to concrete, non-abstract classes that implement the interface. - Each matching implementation is registered against the interface using the
ServiceDescriptor constructor.
Registering in Program.cs
With the extension method in place, registration in Program.cs becomes a single, self-maintaining line, as follows.
builder.Services.AddHandlers<INotificationHandler>();
From this point on, any new handler you add to the project that implements INotificationHandler will be picked up and registered automatically with no further changes to Program.cs required.
Specific Assembly Registration
In larger solutions with multiple projects, you may need to target a specific assembly to scan, rather than the calling assembly. This is straightforward to achieve by passing the target assembly into the AddHandlers extension method explicitly, as follows.
builder.Services.AddHandlers<INotificationHandler>(typeof(INotificationHandler).Assembly);
This is particularly useful when your handlers live in a separate class library, keeping the registration logic decoupled from any particular entry point.
Handler Injection
The ASP.NET Core Dependency Injection container supports injecting multiple registrations of the same interface as an IEnumerable<T>. This means any class that depends on all registered handlers simply needs to declare IEnumerable<INotificationHandler> as a constructor parameter.
Dispatching
Below is an example of an NotificationDispatcher class that fans out to every registered handler.
namespace HandlerDemo; public class NotificationDispatcher {     private readonly IEnumerable<INotificationHandler> _handlers;     public NotificationDispatcher(IEnumerable<INotificationHandler> handlers)     {         _handlers = handlers;     }     public async Task DispatchAsync(string notification)     {         foreach (var handler in _handlers)         {             await handler.HandleAsync(notification);         }     } }
The NotificationDispatcher can be registered as follows.
builder.Services.AddTransient<NotificationDispatcher>();
It can then be injected wherever it is needed, for example, into a Minimal API endpoint, as shown below.
app.MapPost("/notify", async (string message, NotificationDispatcher dispatcher) => {     await dispatcher.DispatchAsync(message);     return Results.Ok(); });
Sending a HTTP POST request to the /notify endpoint will call into the dispatcher, which in turn will fan out to every registered handler. In this case, both the EmailNotificationHandler and SmsNotificationHandler.
Summary
Auto-registering handler interface implementations via Reflection is a clean and scalable approach that eliminates the need to maintain a growing list of service registrations manually. The key points from this post are highlighted below.
- The ASP.NET Core Dependency Injection container supports multiple registrations of the same interface, injectable as
IEnumerable<T>. - A generic extension method that calls the
Assembly.GetTypes()method and theServiceDescriptorconstructor can be used to discover and register all handler implementations automatically. - The approach is flexible; you can control the target assembly and service lifetime without changing any of the auto-registration logic.
- Adding a new handler requires no changes to
Program.cs, making the solution easy to extend over time.


Comments