Create your own Mediator (like Mediatr)

21/06/2023
MediatorC#.NET

In this blog post, I'll show you the fundamentals of the Mediator pattern and how to implement it in your application from scratch. And yes, we basically implement the famous MediatR library.

Mediator

Before we go into code or more details, let's explore what the Mediator pattern is and what it is used for. The Mediator pattern is a behavioral design pattern that allows us to expose a unified interface through which the different parts of a system may communicate. It is also known as the Publish-Subscribe pattern. The Mediator pattern promotes loose coupling by keeping objects from referring to each other explicitly, and it lets you vary their interaction independently.

If we look at the UML diagram of the Mediator pattern, we can see that the Mediator is the central component that coordinates the communication between the different objects. The Mediator is the only component that has knowledge of the different objects and how they interact with each other. The objects themselves don't know anything about each other. They only know about the Mediator and how to communicate with it.

Mediator

I also had a blog post about the Mediator Pattern and CQRS if you want to dive deeper before coding.

Code

Disclaimer: This source code is meant for educational purposes only. It is not meant to be used in production code. It should show the fundamentals. I would recommend using the MediatR library instead if you want to use the Mediator pattern in your application.

Before we start, I want to define the usage. That is something I often like to do: How does it (the API) feel intuitive so that users only have to do the minimal implementation to have a running example. So from a users point of view there are two things to do:

  1. Register the Mediator
  2. Implement handler for messages

Everything should be done by our piece of code. So from the user point of view, here is a sample of how it should look like:

// That should register all services we have internally as well as everything around
services.AddMediator();

Here an example handler:

public class EmailHandler : INotificationHandler<EmailMessage>
{
    public void Handle(EmailMessage message)
    {
        // Do something with the message
    }
}

And also we need someone that publishes messages:

public class EmailService
{
    private readonly IMediator _mediator;

    public EmailService(IMediator mediator)
    {
        _mediator = mediator;
    }

    public void SendEmail(string message)
    {
        _mediator.Send(new EmailMessage(message));
    }
}

That's it. Now the only two interfaces we have to declare are INotificationHandler<T> and IMediator.

public interface INotificationHandler<T>
{
    void Handle(T notification);
}

public interface IMediator
{
    void Send<T>(T message);
}

In our simple code, the mediator is very slim:

public class Mediator : IMediator
{
    private readonly IServiceProvider _serviceProvider;

    public Mediator(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public void Send<T>(T message)
    {
        var handlers = serviceProvider.GetServices<INotificationHandler<TMessage>>();
        foreach (var handler in handlers)
        {
            handler.Handle(message);
        }
}

We are basically using the IServiceProvider interface and resolve the instance when call Send. Where this solution is very slim and easy, it has some major drawbacks:

  1. Service Locator Anti-pattern: By injecting the IServiceProvider, this implementation is essentially using it as a Service Locator. This can make the dependencies of your class less explicit and harder to manage as your application grows. It's generally considered an anti-pattern because it hides the class' dependencies, making the code more difficult to maintain and understand. It makes unit testing also harder than it should be.
  2. No error handling whatsoever: If the handler is not registered, we will get a NullReferenceException when calling Handle.
  3. Resolving instances on every Send call can get very expensive, especially if Send is called regularly.

We want a dedicated type that holds all those types - kind of like a registry. That is anyway better, as we want to be SOLID principle compliant. If we have all the functionality in one class, we would violate the Single Responsibility Principle. So let's create a class that holds all the handlers:

public class NotificationHandlerRegistry
{
    // The handler has to hold objects as key, as we don't know the type of the message yet
    private readonly Dictionary<Type, object> handlers = new();

    public void AddHandler<T>(INotificationHandler<T> handler)
    {
        var messageType = typeof(T);

        if (!handlers.ContainsKey(messageType))
        {
            handlers[messageType] = new List<INotificationHandler<T>>();
        }

        ((List<INotificationHandler<T>>)handlers[messageType]).Add(handler);
    }

    public bool HasHandler<T>() => handlers.ContainsKey(typeof(T));

    public IReadOnlyCollection<INotificationHandler<T>> GetHandlers<T>()
        => handlers.TryGetValue(typeof(T), out var list) ? (List<INotificationHandler<T>>)list : Array.Empty<INotificationHandler<T>>();
}

Now we can change our Mediator class to use this registry:

public class Mediator : IMediator
{
    private readonly NotificationHandlerRegistry handlerRegistry;

    public Mediator(NotificationHandlerRegistry handlerRegistry)
    {
        this.handlerRegistry = handlerRegistry;
    }

    public void Send<TMessage>(TMessage message)
    {
        if (!handlerRegistry.HasHandler<TMessage>())
        {
            throw new InvalidOperationException($"No handler registered for message type {typeof(TMessage).Name}");
        }
        var handlers = handlerRegistry.GetHandlers<TMessage>();
        foreach (var handler in handlers)
        {
            handler.Handle(message);
        }
    }
}

But hey there is still one thing missing: Where do we retrieve all the types and put them into the registry? And this is now where the circle closes:

public static class MediatorExtensions
{
    public static IServiceCollection AddMediator(this IServiceCollection services, params Assembly[] assemblies)
    {
        // We allow multiple assemblies to scan - if the user doesn't add anything, we assume the current one
        // where we are looking for INotificationHandler<T>
        if (assemblies.Length == 0)
        {
            assemblies = new[] { Assembly.GetExecutingAssembly() };
        }

        services.AddSingleton<IMediator, Mediator>();

        // Register all handler types found in the specified assemblies.
        foreach (var assembly in assemblies)
        {
            var handlerTypes = assembly.ExportedTypes
                .Where(x => x.GetInterfaces().Any(y => y.IsGenericType && y.GetGenericTypeDefinition() == typeof(INotificationHandler<>)))
                .ToList();

            foreach (var handlerType in handlerTypes)
            {
                services.AddTransient(handlerType);
            }
        }

        // Build the handler registry using the registered handlers.
        services.AddSingleton<NotificationHandlerRegistry>(provider =>
        {
            var registry = new NotificationHandlerRegistry();

            foreach (var service in services)
            {
                if (service.ServiceType.GetInterfaces().Any(y => y.IsGenericType && y.GetGenericTypeDefinition() == typeof(INotificationHandler<>)))
                {
                    var handler = provider.GetServices(service.ServiceType);
                    foreach (var h in handler.Where(s => s is not null))
                    {
                        var handlerInterface = h.GetType().GetInterfaces().First();
                        var messageType = handlerInterface.GetGenericArguments().First();
                        typeof(NotificationHandlerRegistry)
                            .GetMethod("AddHandler")
                            .MakeGenericMethod(messageType)
                            .Invoke(registry, new[] { h });
                    }
                }
            }

            return registry;
        });

        return services;
    }
}

As we can see, quite some code. In its core, we register our INotificaionHander<> so that also those implementations can used the dependency injection container. The we build up our registration. And it pays, because from an users point of view, it looks like this:

var services = new ServiceCollection()
    .AddMediator()
    .AddScoped<ProducerService>()
    .BuildServiceProvider();

var producer = services.GetRequiredService<ProducerService>();
producer.ProduceEmail("Hello World");

Here the producer:

public class ProducerService
{
    private readonly IMediator _mediator;

    public ProducerService(IMediator mediator)
    {
        _mediator = mediator;
    }

    public void ProduceEmail(string subject)
    {
        _mediator.Send(new EmailMessage(subject));
    }
}

And two of our handlers:

public class EmailSender : INotificationHandler<EmailMessage>
{
    public void Handle(EmailMessage notification)
    {
        Console.WriteLine($"Inside {nameof(EmailSender)}.Handle()...");
        Console.WriteLine($"Sending email with subject {notification.Subject}...");
    }
}

public class EmailArchiver : INotificationHandler<EmailMessage>
{
    public void Handle(EmailMessage notification)
    {
        Console.WriteLine($"Inside {nameof(EmailArchiver)}.Handle()...");
        Console.WriteLine($"Archiving email with subject {notification.Subject}...");
    }
}

The output looks like this:

Inside EmailSender.Handle()...
Sending email with subject Hello World...
Inside EmailArchiver.Handle()...
Archiving email with subject Hello World...

Conclusion

With SOLID principles in mind, we have created a simple, easy-to-use and extend mediator implementation. It is not perfect, but it is a good starting point. Of course, there are many areas of improvement, but I hope I could show you how the internals (simplified) work. The source code for this article is listed below.

Resources

  • Source code to this blog post: here
  • All my sample code is hosted in this repository: here
11
An error has occurred. This application may no longer respond until reloaded. Reload x