Search code examples
c#genericsdependency-injectionautofac

Is it possible to use constructor injection with a generic type?


I have event handlers that currently have class and method signatures like this:

public class MyEventHandler : IIntegrationEventHandler

public void Handle(IntegrationEventintegrationEvent) // IntegrationEvent is the base class

What I want to do is this (so that the handler can accept the concrete type):

public class MyEventHandler : IIntegrationEventHandler<MyEvent>

public void Handle(MyEvent integrationEvent)

In Startup.cs, I did this:

services.AddTransient<IIntegrationEventHandler<MyEvent>, MyEventHandler>();

The issue is that the service that gets these handlers injected can't use open generics, like this:

public MyService(IEnumerable<IIntegrationEventHandler<>> eventHandlers)
            : base(eventHandlers)

Specifying the base class doesn't work either:

public MyService(IEnumerable<IIntegrationEventHandler<IntegrationEvent>> eventHandlers)
            : base(eventHandlers)

That gives me 0 handlers.

The current approach works in that I get all 7 handlers injected. But that means the handler has to accept the base class in its method and then cast it. Not a huge deal, but would be nice if I could have the handler accept the concrete type it cares about. Is this possible?


Solution

  • What you request cannot be done directly. C# collections are always bound to a specific item type and that type must be cast if a different type is desired. And IIntegrationEventHandler<MyEvent> and IIntegrationEventHandler<DifferentEvent> are different types.

    As an alternate solution, you could dispatch events through an intermediary (dispatcher) and route them to concrete handler type using reflection and DI. The handlers will be registered with DI with the specific event type they declare to handle. You can also handle multiple types of events with a single handler implementation. The dispatcher will inject a collection of concrete event handlers based on run-time type of the received event directly from IServiceProvider.

    I have not compiled or tested the following code but it should give you the general idea.

    Startup.cs

    services
        .AddTransient<IIntegrationEventHandler<MyEvent>, MyEventHandler>()
        .AddTransient<IntegrationEventDispatcher>();
    

    IntegrationEventDispatcher.cs

    private readonly IServiceProvider _serviceProvider;
    
    public IntegrationEventDispatcher(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }
    
    public void Dispatch(IntegrationEvent @event)
    {
        var eventType = @event.GetType();
    
        // TODO: Possibly cache the below reflected types for improved performance
        var handlerType = typeof(IIntegrationEventHandler<>).MakeGenericType(eventType);
        var handlerCollectionType = typeof(IEnumerable<>).MakeGenericType(handlerType);
        var handlers = (IEnumerable)_serviceProvider.GetService(handlerCollectionType);
    
        var handleMethod = handlerType.GetMethod("Handle", eventType);
    
        foreach (var handler in handlers)
        {
            handleMethod.Invoke(handler, new[] { @event });
        }
    }
    

    MyService.cs

    // ...
    
    private readonly IntegrationEventDispatcher  _eventDispatcher;
    
    public MyService(IntegrationEventDispatcher eventDispatcher)
    {
        _eventDispatcher = eventDispatcher;
    }
    
    public void DoStuffAndDispatchEvents()
    {
        // ...
        _eventDispatcher.Dispatch(new MyEvent());
        _eventDispatcher.Dispatch(new DifferentEvent());
    }
    

    Edit:

    Generics-based dispatcher implementation (gist):

    public void Dispatch<TEvent>(TEvent @event) where TEvent : IntegrationEvent 
    {
        var handlers = _serviceProvider.GetRequiredService<IEnumerable<IIntegrationEventHandler<TEvent>>>();
    
        foreach (var handler in handlers)
        {
            handler.Handle(@event);
        }
    }
    

    Edit 2: In order to support event handling from the base type, there are a couple of approaches that come to my mind:

    a) Use the reflection-based approach from above.

    Performance-wise this won't be the best but it will work for any type it will receive.

    b) Use a type switch

    Switch on event type to invoke Dispatch<T> with proper type. The downside is that you need to list all supported event types and update the list when any new event type is introduced. Also, this might be somewhat tricky to catch with tests.

    The IntegrationEventDispatcher logic becomes

        public void Dispatch(IntegrationEvent @event)
        {
            switch (@event)
            {
                case MyIntegrationEvent e:
                    Dispatch(e); // Calls Dispatch<TEvent> as the overload with a more specific type
                    break;
    
                case OtherIntegrationEvent e:
                    Dispatch(e);
                    break;
    
                default:
                    throw new NotSupportedException($"Event type {@event.GetType().FullName} not supported.");
            }
        }
    
        private void Dispatch<TEvent>(TEvent @event) where TEvent : IntegrationEvent
        {
            var handlers = _serviceProvider.GetRequiredService<IEnumerable<IEventHandler<TEvent>>>();
    
            foreach (var handler in handlers)
            {
                handler.Handle(@event);
            }
        }
    

    Gist with a minimal implementation is available here.

    c) Use visitor pattern

    Make base event type accept a visitor which visits the concrete event type. This is quite a bit more code and requires changes to the base event type. When new event type is added, it is automatically supported, although a new overload may be necessary if overloads are used instead of generic method (as in proper visitor).

    A IIntegrationEventVisitor should exist on the same level as IntegrationEvent - that may be an architectural issue, however, since events are already designed as objects, I would expect that having a behavior should not be a problem, especially this abstract.

    IntegrationEvent.cs

    public abstract class IntegrationEvent
    {
        public abstract void Accept(IIntegrationEventVisitor visitor);
    }
    

    MyIntegrationEvent.cs

    public class MyIntegrationEvent : IntegrationEvent
    {
        public override void Accept(IIntegrationEventVisitor visitor)
        {
            visitor.Visit(this); // "this" is the concrete type so TEvent is inferred properly
        }
    }
    

    IIntegrationEventVisitor.cs

    public interface IIntegrationEventVisitor
    {
        // Note: This is not a proper visitor, feel free to implement 
        // overloads for individual concrete event types for proper design.
        // Generic method is not very useful for a visitor in general
        // so this is actually an abstraction leak.
        void Visit<TEvent>(TEvent @event) where TEvent : IntegrationEvent;
    }
    

    IntegrationEventDispatcher.cs

    public class IntegrationEventDispatcher : IIntegrationEventVisitor
    {
        // Previously known as Dispatch
        public void Visit<TEvent>(TEvent @event) where TEvent : IntegrationEvent
        {
            var handlers = _serviceProvider.GetRequiredService<IEnumerable<IEventHandler<TEvent>>>();
    
            foreach (var handler in handlers)
            {
                handler.Handle(@event);
            }
        }
    }
    

    Gist with a minimal implementation is available here.

    d) Take a step back

    If you want to dispatch a base type, maybe injecting concrete handlers is not what you need at all and there could be another way.

    How about we create an event handler that takes a base type and handles it only if it's relevant? But let's not duplicate these relevance checks in each handler, let's make a class that does it for us in general and delegates to proper handlers.

    We will use IIntegrationEventHandler as a general handler to accept the base type, implement it with a generic type that checks if the accepted type is relevant and then forwards it to handlers of that type which implement IIntegrationEventHandler<TEvent>.

    This approach will let you use constructor injection everywhere as you originally requested and generally feels like the closest to your original approach. The downside is that all handlers are instantiated even if they are not used. This could be avoided with e.g. Lazy<T> over the concrete handlers collection.

    Note that there is no generic Dispatch method or any of its variant, you just inject a collection of IIntegrationEventHandler where each delegates to collection of IIntegrationEventHandler<TEvent> if the received event type is TEvent.

    IIntegrationEventHandler.cs

    public interface IIntegrationEventHandler
    {
        void Handle(IntegrationEvent @event);
    }
    
    public interface IIntegrationEventHandler<TEvent> where TEvent : IntegrationEvent
    {
        void Handle(TEvent @event);
    }
    

    DelegatingIntegrationEventHandler.cs

    public class DelegatingIntegrationEventHandler<TEvent> : IIntegrationEventHandler where TEvent : IntegrationEvent
    {
        private readonly IEnumerable<IEventHandler<TEvent>> _handlers;
    
        public DelegatingIntegrationEventHandler(IEnumerable<IEventHandler<TEvent>> handlers)
        {
            _handlers = handlers;
        }
    
        public void Handle(IntegrationEvent @event)
        {
            // Check if this event should be handled by this type
            if (!(@event is TEvent concreteEvent))
            {
                return;
            }
    
            // Forward the event to concrete handlers
            foreach (var handler in _handlers)
            {
                handler.Handle(concreteEvent);
            }
        }
    }
    

    MyIntegrationEventHandler.cs

    public class MyIntegrationEventHandler : IIntegrationEventHandler<MyIntegrationEvent>
    {
        public void Handle(MyIntegrationEvent @event)
        {
            // ...
        }
    }
    

    Gist with a minimal implementation is available here. Check it out for actual setup and usage as it is slightly more complex to get it right.