Mediator Pattern in C#: Fewer Couplings, Clearer Flows

Cutting Through Component Spaghetti

If you've ever had a UI component that talks directly to three other components, which each talk to two more, you know how quickly interactions become tangled. Adding a feature means updating every component in the chain because they all know about each other.

The Mediator pattern centralizes communication. Instead of components referencing each other directly, they send messages through a mediator. The mediator handles routing and coordination. Components stay loosely coupled because they only depend on the mediator interface, not on each other.

You'll build a chat room where users send messages without knowing about other users. The mediator manages who receives what. By the end, you'll know when centralized coordination beats direct component interaction.

The Problem Without a Mediator

When components communicate directly, each must know about the others. In a chat system where users message each other, every user needs references to all other users. Adding or removing a user means updating everyone's reference lists.

TightlyCoupled.cs
namespace MediatorDemo;

public class UserDirectCoupled
{
    private readonly List<UserDirectCoupled> _peers = new();
    public string Name { get; }

    public UserDirectCoupled(string name) => Name = name;

    public void AddPeer(UserDirectCoupled peer) => _peers.Add(peer);

    public void SendMessage(string message)
    {
        Console.WriteLine($"{Name} sends: {message}");
        foreach (var peer in _peers)
        {
            peer.ReceiveMessage(message, this);
        }
    }

    public void ReceiveMessage(string message, UserDirectCoupled sender)
    {
        if (sender != this)
            Console.WriteLine($"{Name} received from {sender.Name}: {message}");
    }
}

// Usage requires wiring every user to every other user
var alice = new UserDirectCoupled("Alice");
var bob = new UserDirectCoupled("Bob");
var charlie = new UserDirectCoupled("Charlie");

alice.AddPeer(bob);
alice.AddPeer(charlie);
bob.AddPeer(alice);
bob.AddPeer(charlie);
charlie.AddPeer(alice);
charlie.AddPeer(bob);

This tight coupling makes the system brittle. Each user knows about every other user. When someone leaves or joins, you must update all peer lists. The mediator pattern eliminates this by centralizing the participant list.

Defining the Mediator

The mediator interface defines how components send messages. Components only need to know about this interface, not about other components. The concrete mediator handles routing.

IChatMediator.cs
namespace MediatorDemo;

public interface IChatMediator
{
    void SendMessage(string message, User sender);
    void RegisterUser(User user);
}

public class ChatRoom : IChatMediator
{
    private readonly List<User> _users = new();

    public void RegisterUser(User user)
    {
        _users.Add(user);
        Console.WriteLine($"{user.Name} joined the chat");
    }

    public void SendMessage(string message, User sender)
    {
        foreach (var user in _users)
        {
            if (user != sender)
            {
                user.ReceiveMessage(message, sender.Name);
            }
        }
    }
}

The ChatRoom mediator manages the user list and handles message distribution. Users don't know about each other. They only interact with the mediator interface.

Components Using the Mediator

Each component holds a reference to the mediator but not to other components. When a component needs to communicate, it tells the mediator. The mediator decides what happens next.

User.cs
namespace MediatorDemo;

public class User
{
    private readonly IChatMediator _mediator;
    public string Name { get; }

    public User(string name, IChatMediator mediator)
    {
        Name = name;
        _mediator = mediator;
        _mediator.RegisterUser(this);
    }

    public void Send(string message)
    {
        Console.WriteLine($"{Name} sends: {message}");
        _mediator.SendMessage(message, this);
    }

    public void ReceiveMessage(string message, string senderName)
    {
        Console.WriteLine($"{Name} received from {senderName}: {message}");
    }
}

Users send messages through the mediator. They don't call other users directly. This decoupling means you can add users, change routing rules, or introduce filters without touching the User class.

Putting It Together

Create the mediator first, then pass it to components as they're constructed. Components register themselves with the mediator and communicate through it exclusively.

Program.cs
using MediatorDemo;

var chatRoom = new ChatRoom();

var alice = new User("Alice", chatRoom);
var bob = new User("Bob", chatRoom);
var charlie = new User("Charlie", chatRoom);

alice.Send("Hello everyone!");
bob.Send("Hi Alice!");
charlie.Send("Hey folks!");

Users interact through the mediator. No user knows about any other user directly. You can add a fourth user without changing alice, bob, or charlie. The mediator handles all coordination.

Try It Yourself

Build a simple mediator that coordinates button clicks in a dialog. This shows how mediators manage interactions between UI components without direct coupling.

Steps

  1. Init: dotnet new console -n MediatorLab
  2. Enter: cd MediatorLab
  3. Edit Program.cs
  4. Configure .csproj
  5. Start: dotnet run
Demo.cs
var dialog = new DialogMediator();
var okBtn = new Button("OK", dialog);
var cancelBtn = new Button("Cancel", dialog);

okBtn.Click();
cancelBtn.Click();

interface IDialogMediator
{
    void Notify(string sender, string ev);
}

class DialogMediator : IDialogMediator
{
    public void Notify(string sender, string ev)
    {
        if (sender == "OK" && ev == "click")
            Console.WriteLine("Mediator: Validating and closing dialog");
        else if (sender == "Cancel" && ev == "click")
            Console.WriteLine("Mediator: Discarding changes and closing");
    }
}

class Button
{
    private readonly string _name;
    private readonly IDialogMediator _mediator;

    public Button(string name, IDialogMediator mediator)
    {
        _name = name;
        _mediator = mediator;
    }

    public void Click() => _mediator.Notify(_name, "click");
}
MediatorLab.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

Output

Mediator: Validating and closing dialog
Mediator: Discarding changes and closing

Real-World Usage

In ASP.NET Core applications, libraries like MediatR provide a mediator implementation with built-in dependency injection support. You define request and handler classes, and MediatR routes requests to the appropriate handlers. This pattern works particularly well for CQRS, where commands and queries flow through a central pipeline.

Register MediatR in your DI container and inject IMediator into controllers or services. Define requests as simple classes and create corresponding handler classes. MediatR discovers handlers automatically and provides pipeline behaviors for cross-cutting concerns like logging, validation, and transactions.

For observability, add logging in the mediator or use pipeline behaviors that wrap handlers. Log when requests start and finish, along with their types and execution times. Use structured logging with request properties as log context. Add counters for request throughput and timers for handler duration to track performance trends over time.

FAQ

When should I use the Mediator pattern?

Use mediators when components need to communicate but you want to avoid direct dependencies. If adding a feature requires changing five classes, mediator centralizes that logic. It works best for workflows, dialogs, and orchestrations where coupling would spread across many objects.

What's the gotcha with mediator becoming a god object?

Mediators can absorb too much logic if you're not careful. Keep handlers focused on coordination, not business rules. If your mediator has thousands of lines, split it into multiple mediators by domain or use handlers that delegate to services for actual work.

How does MediatR library simplify this pattern?

MediatR provides request/response and notification patterns out of the box with handler discovery via DI. You define request classes and corresponding handlers, and MediatR routes messages automatically. This eliminates boilerplate and integrates cleanly with ASP.NET Core pipelines.

Does mediator work well with async operations?

Yes, mediators handle async naturally. Define handlers that return Task or Task<T> and await them when dispatching requests. MediatR supports async throughout. Just ensure your pipeline behaviors and handlers properly await operations to avoid blocking threads.

Is it safe to use mediator in high-throughput scenarios?

Mediators add a layer of indirection, which has minimal overhead. MediatR uses reflection for handler resolution, but caches types. Profile your hot paths. For extreme throughput, consider direct dependencies or source generators to eliminate runtime reflection costs.

Back to Articles