Today, let’s explore the Mediator design pattern—an approach that promotes loose coupling between components by centralizing their communication. This pattern becomes especially relevant now that the popular MediatR library has switched to a commercial license. While many companies can still use it for free, the license terms could change in the future. So, it’s wise to be prepared with alternatives.
That said, licensing concerns shouldn't discourage you from using the Mediator pattern itself - it remains a powerful architectural tool.
🤔 What Is the Mediator Pattern?
The Mediator is a behavioral design pattern that allows components (often called colleagues) to communicate through a central mediator rather than referencing each other directly.
In traditional designs, components talk to one another directly:
Over time, this tight coupling creates a tangled web of dependencies, making the system hard to change and test. The Mediator pattern addresses this by introducing a central object to handle all communication:
🧪 Implementing the Mediator Pattern in Code
Let’s consider a simple chat application. We’ll use the Mediator pattern to decouple users from each other.
Step 1: Define the Mediator Interface
public interface IChatMediator
{
void SendMessage(string message, User sender);
void AddUser(User user);
}
Step 2: Create the Concrete Mediator (Chat Room)
The chat room will be the Mediator that has two concrete methods that are implementing adding a user to the chat room and sending a message.
public class ChatRoom : IChatMediator
{
private readonly List<User> _users = new();
public void AddUser(User user)
{
_users.Add(user);
}
public void SendMessage(string message, User sender)
{
foreach (var user in _users)
{
if (user != sender)
{
user.ReceiveMessage(message, sender);
}
}
}
}
Step 3: Define the User
Let's also create a user who can initiate sending and receiving messages.
public class User
{
private readonly IChatMediator _mediator;
private string Name { get; }
public User(string name, IChatMediator mediator)
{
Name = name;
_mediator = mediator;
_mediator.AddUser(this);
}
public void Send(string message)
{
Console.WriteLine($"{Name} sends: {message}");
_mediator.SendMessage(message, this);
}
public void ReceiveMessage(string message, User sender)
{
Console.WriteLine($"{Name} receives from {sender.Name}: {message}");
}
}
Step 4: Run the Example
Let's use it, create more users, and send messages.
public void MediatorApproach()
{
IChatMediator chatRoom = new ChatRoom();
var user1 = new User("Alice", chatRoom);
var user2 = new User("Bob", chatRoom);
var user3 = new User("Charlie", chatRoom);
user1.Send("Hello everyone!");
user2.Send("Hi Alice!");
user3.Send("Hey folks!");
}
If you run it, you'll see this result.
Alice sends: Hello everyone!
Bob receives from Alice: Hello everyone!
Charlie receives from Alice: Hello everyone!
Bob sends: Hi Alice!
Alice receives from Bob: Hi Alice!
Charlie receives from Bob: Hi Alice!
Charlie sends: Hey folks!
Alice receives from Charlie: Hey folks!
Bob receives from Charlie: Hey folks!
Process finished with exit code 0.
Communication occurs through the chat room, also known as the Mediator.
🧩 Using MediatR Instead
As I mentioned earlier, there is an ultimate solution, and you can use the NuGet package MediatR
. This implementation is more straightforward. MediatR provides a clean and popular solution. You need to implement the notification class and handler.
public class ChatMessage(string sender, string message) : INotification
{
public string Sender { get; } = sender;
public string Message { get; } = message;
}
public class ChatRoomHandler : INotificationHandler<ChatMessage>
{
private static readonly List<string> Users = ["Alice", "Bob", "Charlie"];
public Task Handle(ChatMessage notification, CancellationToken cancellationToken)
{
Console.WriteLine($"{notification.Sender} sends: {notification.Message}");
foreach (var user in Users.Where(user => user != notification.Sender))
{
Console.WriteLine($"{user} receives from {notification.Sender}: {notification.Message}");
}
return Task.CompletedTask;
}
}
This approach requires only two interface implementations and the addition of specific logic. As a rule, each handler is responsible for one action and also has its own notification. In an extensive application, it leads to a large number of messages and handlers. However, each pair of a message and a handler is isolated and doesn't affect other handlers.
The use is also different. You must register MediatR. Next, you should publish only messages. MedaitR by type will call the target handler.
public static async Task MediatrApproach()
{
var services = new ServiceCollection();
services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining<Program>());
var provider = services.BuildServiceProvider();
var mediator = provider.GetRequiredService<IMediator>();
await mediator.Publish(new ChatMessage("Alice", "Hello everyone!"));
await mediator.Publish(new ChatMessage("Bob", "Hi Alice!"));
await mediator.Publish(new ChatMessage("Charlie", "Hey folks!"));
}
🚫 When Not to Use a Mediator
If your app is small, unlikely to scale, or contains only a few components, you might not need a Mediator at all.
Here’s a naive implementation using direct coupling:
public class ChatRoom
{
private readonly List<User> _users = [];
public void RegisterUser(User user)
{
_users.Add(user);
}
public void Broadcast(string message, User sender)
{
foreach (var user in _users)
{
if (user != sender)
{
user.ReceiveMessage(message, sender);
}
}
}
}
public class User
{
private string Name { get; }
private readonly ChatRoom _chatRoom;
public User(string name, ChatRoom chatRoom)
{
Name = name;
_chatRoom = chatRoom;
_chatRoom.RegisterUser(this);
}
public void Send(string message)
{
Console.WriteLine($"{Name} sends: {message}");
_chatRoom.Broadcast(message, this);
}
public void ReceiveMessage(string message, User sender)
{
Console.WriteLine($"{Name} received from {sender.Name}: {message}");
}
}
The use of this approach is similar to that of Mediator. You create the chat room, new users, and send messages. The code is almost the same.
public static void NaiveApproach()
{
var chatRoom = new MediatorPattern.Naive.ChatRoom();
var alice = new MediatorPattern.Naive.User("Alice", chatRoom);
var bob = new MediatorPattern.Naive.User("Bob", chatRoom);
var charlie = new MediatorPattern.Naive.User("Charlie", chatRoom);
alice.Send("Hello everyone!");
bob.Send("Hi Alice!");
charlie.Send("Hey folks!");
}
📊 What About Performance?
As you can guess, the difference between the first and third approaches is minimal. The key difference is only in maintainability and scalability. However, how can a third-party library affect performance? You don't know what was done under the hood, where you don't have control.
As expected, third-party libraries like MediatR do introduce slight overhead.
✅ Conclusion
The Mediator pattern is not about performance - it’s about maintainability, modularity, and scalability.
✅ Use the naive approach for quick prototypes or small apps.
✅ Implement your own Mediator for full control and clarity.
✅ Use MediatR for larger applications or when you want to embrace a fully decoupled architecture - but keep the licensing in mind.
Top comments (1)
How do you handle complex workflows (like sagas) with the Mediator pattern?
Can I use Mediator for domain events as well or should I separate it from application events?