There are actually two points of discussion in your question. One is a point related to the design of the code, the other one is a point related on how to use the .NET core DI container in order to handle the required registrations.
Both of them are important, but we need to treat them one at a time.
How to organize the code
To solve your problem in a clean and extensibile way you need to use a design pattern known as the composite design pattern. In order to do so, you need to change the definition of your interface to the following:
public interface IMessageConsumer
{
bool CanHandleMessage(Message message);
Task HandleMessage(Message message);
}
Your interface implementations are then changed as follows:
public class FooMessageConsumer: IMessageConsumer
{
public bool CanHandleMessage(Message message)
{
if (message is null) throw new ArgumentNullException(nameof(message));
return message.Type == "foo";
}
public Task HandleMessage(Message message)
{
if (message is null)
throw new ArgumentNullException(nameof(message));
if (!this.CanHandleMessage(message))
throw new InvalidOperationException($"{nameof(FooMessageConsumer)} can only handle foo messages.");
await Task.Delay(100).ConfigureAwait(false);
Console.Writeline($"Message {message.Id} handled by {nameof(FooMessageConsumer)}");
}
}
public class BarMessageConsumer: IMessageConsumer
{
public bool CanHandleMessage(Message message)
{
if (message is null) throw new ArgumentNullException(nameof(message));
return message.Type == "bar";
}
public Task HandleMessage(Message message)
{
if (message is null)
throw new ArgumentNullException(nameof(message));
if (!this.CanHandleMessage(message))
throw new InvalidOperationException($"{nameof(BarMessageConsumer)} can only handle bar messages.");
await Task.Delay(100).ConfigureAwait(false);
Console.Writeline($"Message {message.Id} handled by {nameof(BarMessageConsumer)}");
}
}
At this point you need to introduce a special message consumer, which will be used to dispatch the message to the proper consumer. This is called the composite message consumer and this is the implementation of IMessageConsumer that you will register in your DI container and that will be injected in all the classes which need a message consumer in order to do their business.
public class CompositeMessageConsumer : IMessageConsumer
{
private readonly IMessageConsumer[] _consumers;
public CompositeMessageConsumer(IEnumerable<IMessageConsumer> consumers)
{
if (consumers is null)
throw new ArgumentNullException(nameof(consumers));
this._consumers = consumers.ToArray();
}
public bool CanHandleMessage(Message message)
{
if (message is null) throw new ArgumentNullException(nameof(message));
return this._consumers.Any(c => c.CanHandleMessage(message));
}
public async Task HandleMessage(Message message)
{
if (message is null)
throw new ArgumentNullException(nameof(message));
if (!this.CanHandleMessage(message))
throw new InvalidOperationException("None of the available consumers is able to handle the provided message.");
var consumer = this._consumers.First(c => c.CanHandleMessage(message));
await consumer.HandleMessage(message).ConfigureAwait(false);
}
}
Here is an example of a class which uses the IMessageConsumer interface. At runtime, the DI container will inject an instance of CompositeMessageConsumer.
// this is an example of a class depending on the IMessageConsumer service
public class MessageProcessor
{
// at runtime this will be an instance of CompositeMessageConsumer
private readonly IMessageConsumer _consumer;
// the DI container will inject an instance of CompositeMessageConsumer here
public MessageProcessor(IMessageConsumer consumer)
{
if (consumer is null) throw new ArgumentNullException(nameof(consumer));
this._consumer = consumer;
}
public async Task ProcessIncomingMessage(Message message)
{
if (message is null) throw new ArgumentNullException(nameof(message));
// do all the pre processing here...
// handle the message
await this._consumer.HandleMessage(message).ConfigureAwait(false);
// do all the post processing here...
}
}
How to register the services on the .NET core DI container
Deciding the proper lifetime for your registrations is a problem that goes beyond the scope of this discussion.
In my example code above I have defined stateless consumer classes and the composite consumer only iterates over the array of the available consumers. The array is never modified during the iteration. This means that all the involved classes are thread safe, so we can register all of them with a singleton lifetime.
That said, the simplest registration that you can perform is the following:
// register the consumers as classes
services.AddSingleton<FooMessageConsumer>();
service.AddSingleton<BarMessageConsumer>();
// register the composite message consumer as an interface, so that when you require IMessageConsumer you get CompositeMessageConsumer
services.AddSingleton<IMessageConsumer>(container =>
{
var fooConsumer = container.GetRequiredService<FooMessageConsumer>();
var barConsumer = container.GetRequiredService<BarMessageConsumer>();
return new CompositeMessageConsumer(new IMessageConsumer[]
{
fooConsumer,
barConsumer
});
});
A great book to learn about these topics is this one. If you are a .NET developer this is definitely a must read.