BrandGhost
Mediator Design Pattern in C#: Complete Guide with Examples

Mediator Design Pattern in C#: Complete Guide with Examples

Mediator Design Pattern in C#: Complete Guide with Examples

When multiple objects need to communicate with each other and the web of direct references starts looking like spaghetti, the mediator design pattern in C# is the behavioral pattern that cleans it up. Instead of letting objects call each other directly, a mediator object sits in the middle and coordinates all communication. Each participant only knows about the mediator -- not about the other participants. This single change transforms a tightly coupled network of many-to-many dependencies into a clean, centralized hub that's far easier to understand, test, and extend.

In this complete guide, we'll walk through everything you need to know about the mediator pattern -- from the core structure and participants to practical C# implementations, an event-driven variation, and how the popular MediatR library applies the same concept. By the end, you'll have working code examples and a clear understanding of when this pattern is the right fit for your application.

What Is the Mediator Design Pattern?

The mediator design pattern is a behavioral design pattern from the Gang of Four (GoF) catalog that defines an object encapsulating how a set of objects interact. It promotes loose coupling by keeping objects from referring to each other explicitly and lets you vary their interactions independently.

Think about an air traffic control tower. Aircraft don't communicate directly with every other aircraft on the runway and in the airspace. That would be chaos -- every pilot would need to know about every other plane, and adding one more aircraft would increase the communication complexity dramatically. Instead, the control tower acts as a mediator. Each pilot talks to the tower, and the tower coordinates all the movements. The pilots don't need to know about each other. The tower handles it.

The mediator pattern works the same way in code. Without it, a group of N objects that all need to communicate with each other produces up to N×(N-1) direct references. That's a combinatorial explosion of dependencies. The mediator pattern replaces that mesh with N references to a single mediator object, dramatically reducing the coupling in your system.

The pattern involves four key participants:

  • The Mediator interface declares the communication contract -- typically a method for sending messages or notifications between colleagues.
  • The Concrete Mediator implements the coordination logic. It knows about all the colleague objects and routes messages between them according to the application's rules.
  • The Colleague is a base class or interface for objects that communicate through the mediator. Each colleague holds a reference to the mediator but has no direct reference to other colleagues.
  • Concrete Colleagues are the specific objects that participate in the mediated communication. They send messages through the mediator and receive messages from it.

This structure means you can change how objects interact by modifying the mediator alone. The colleagues don't change. You can also swap in a different mediator implementation to alter the coordination behavior entirely -- a direct application of inversion of control principles.

When to Use the Mediator Pattern

The mediator pattern shines in specific scenarios. Knowing when to apply it -- and when to avoid it -- is just as important as understanding how it works.

Use the mediator pattern when a set of objects communicate in well-defined but complex ways. If the resulting interdependencies are unstructured and hard to understand, the mediator centralizes the logic and makes it explicit. Instead of tracing communication across a dozen classes, you can read the mediator and understand the entire flow.

Use it when you want to customize behavior by swapping coordination logic. Because the mediator encapsulates how objects interact, you can introduce new mediator implementations to change the behavior without touching the participating objects. This is useful in scenarios like switching between different workflow rules or chat room policies.

Use it when reusing an object is difficult because it refers to many other objects. The mediator pattern breaks those direct dependencies. Each object only depends on the mediator interface, making it portable across different contexts.

Avoid the mediator pattern when the communication between objects is simple. If you have two or three objects with straightforward interactions, introducing a mediator adds indirection without benefit. The pattern earns its keep when the alternative is a tangled web of cross-references.

Watch out for the "God Object" anti-pattern. A mediator that accumulates too much logic becomes a monolithic coordinator that's difficult to maintain. If your mediator class is growing into hundreds of lines of routing and business logic, it's time to decompose it or reconsider your design. The mediator should coordinate interactions, not implement business rules.

Implementing the Mediator Pattern in C#

Let's start with a classic implementation that shows the pattern's structure clearly. We'll build a simple messaging system where colleagues communicate through a mediator.

Defining the Mediator and Colleague Interfaces

First, we define the contracts:

namespace MediatorPattern.Classic;

public interface IMediator
{
    void SendMessage(string message, Colleague sender);
}

public abstract class Colleague
{
    protected IMediator Mediator { get; }

    protected Colleague(IMediator mediator)
    {
        Mediator = mediator;
    }

    public abstract void ReceiveMessage(string message);
}

The IMediator interface declares a single SendMessage method that takes the message content and the sender. The Colleague base class holds a reference to the mediator and defines an abstract ReceiveMessage method that concrete colleagues must implement.

Creating Concrete Colleagues

Now we create two types of participants -- a developer and a project manager -- that communicate through the mediator:

using System;

namespace MediatorPattern.Classic;

public sealed class Developer : Colleague
{
    public string Name { get; }

    public Developer(IMediator mediator, string name)
        : base(mediator)
    {
        Name = name;
    }

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

    public override void ReceiveMessage(string message)
    {
        Console.WriteLine(
            $"[{Name}] received: {message}");
    }
}

public sealed class ProjectManager : Colleague
{
    public string Name { get; }

    public ProjectManager(IMediator mediator, string name)
        : base(mediator)
    {
        Name = name;
    }

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

    public override void ReceiveMessage(string message)
    {
        Console.WriteLine(
            $"[{Name}] received: {message}");
    }
}

Both Developer and ProjectManager extend Colleague. They send messages by calling Mediator.SendMessage() and receive messages through the ReceiveMessage override. Neither class knows about the other. All communication flows through the mediator.

Building the Concrete Mediator

The concrete mediator knows about all participants and defines the routing logic:

using System.Collections.Generic;

namespace MediatorPattern.Classic;

public sealed class TeamMediator : IMediator
{
    private readonly List<Colleague> _colleagues = new();

    public void Register(Colleague colleague)
    {
        if (!_colleagues.Contains(colleague))
        {
            _colleagues.Add(colleague);
        }
    }

    public void SendMessage(
        string message,
        Colleague sender)
    {
        foreach (var colleague in _colleagues)
        {
            if (colleague != sender)
            {
                colleague.ReceiveMessage(message);
            }
        }
    }
}

The TeamMediator maintains a list of registered colleagues. When SendMessage is called, it iterates through all colleagues and delivers the message to everyone except the sender. This is a broadcast-style mediator -- the simplest form of mediation.

Putting It All Together

using MediatorPattern.Classic;

var mediator = new TeamMediator();

var alice = new Developer(mediator, "Alice");
var bob = new Developer(mediator, "Bob");
var carol = new ProjectManager(mediator, "Carol");

mediator.Register(alice);
mediator.Register(bob);
mediator.Register(carol);

alice.Send("The build is broken.");
carol.Send("Let's triage in 10 minutes.");

When Alice sends her message, both Bob and Carol receive it -- but Alice doesn't. When Carol responds, both Alice and Bob receive Carol's message. No participant holds a reference to any other participant. The mediator handles all the routing. If you later add a new team member, you register them with the mediator and they immediately participate in the communication without changing any existing code.

Chat Room Mediator Example

Let's build a more realistic mediator pattern example: a chat room. This scenario maps naturally to the mediator pattern because users in a chat room communicate through the room itself, not by sending messages directly to each other.

using System;
using System.Collections.Generic;

namespace MediatorPattern.ChatRoom;

public interface IChatRoom
{
    void Register(ChatUser user);
    void SendMessage(
        string message,
        ChatUser sender);
    void SendDirectMessage(
        string message,
        ChatUser sender,
        string recipientName);
}

public class ChatUser
{
    private readonly IChatRoom _chatRoom;

    public string Username { get; }

    public ChatUser(IChatRoom chatRoom, string username)
    {
        _chatRoom = chatRoom;
        Username = username;
    }

    public void Send(string message)
    {
        _chatRoom.SendMessage(message, this);
    }

    public void SendTo(string message, string recipient)
    {
        _chatRoom.SendDirectMessage(
            message, this, recipient);
    }

    public virtual void Receive(
        string sender,
        string message)
    {
        Console.WriteLine(
            $"[{Username}] from {sender}: {message}");
    }
}

public sealed class PublicChatRoom : IChatRoom
{
    private readonly Dictionary<string, ChatUser> _users
        = new();

    public void Register(ChatUser user)
    {
        if (!_users.ContainsKey(user.Username))
        {
            _users[user.Username] = user;
            Console.WriteLine(
                $"{user.Username} joined the room.");
        }
    }

    public void SendMessage(
        string message,
        ChatUser sender)
    {
        foreach (var kvp in _users)
        {
            if (kvp.Key != sender.Username)
            {
                kvp.Value.Receive(
                    sender.Username, message);
            }
        }
    }

    public void SendDirectMessage(
        string message,
        ChatUser sender,
        string recipientName)
    {
        if (_users.TryGetValue(
                recipientName, out var recipient))
        {
            recipient.Receive(
                $"{sender.Username} (DM)", message);
        }
        else
        {
            Console.WriteLine(
                $"User '{recipientName}' not found.");
        }
    }
}

This chat room mediator adds a feature the basic example didn't have: direct messaging. The SendDirectMessage method routes a message to a specific recipient by name. The sender doesn't need a reference to the recipient object -- it just provides the name, and the mediator resolves it. This is a concrete example of how the mediator centralizes coordination logic.

Here's how you'd use it:

using MediatorPattern.ChatRoom;

var room = new PublicChatRoom();

var alice = new ChatUser(room, "Alice");
var bob = new ChatUser(room, "Bob");
var carol = new ChatUser(room, "Carol");

room.Register(alice);
room.Register(bob);
room.Register(carol);

alice.Send("Hey everyone!");
bob.SendTo("Can you review my PR?", "Alice");
carol.Send("Meeting in 5 minutes.");

Alice's broadcast reaches Bob and Carol. Bob's direct message only reaches Alice. Carol's broadcast reaches Alice and Bob. All routing happens inside the PublicChatRoom mediator. If you wanted to add features like message filtering, profanity checks, or rate limiting, you'd add them to the mediator without touching the ChatUser class. This is similar to how the facade design pattern simplifies complex subsystem interactions -- the mediator gives participants a simple interface while handling complexity internally.

Event-Driven Mediator Using Delegates

C# delegates and events provide a natural mechanism for building an event-driven mediator. Instead of the mediator explicitly calling methods on colleagues, colleagues subscribe to events on the mediator. This approach is more idiomatic in C# and offers a looser coupling model.

using System;
using System.Collections.Generic;

namespace MediatorPattern.EventDriven;

public sealed class EventMediator
{
    private readonly Dictionary<string, Action<string, string>>
        _handlers = new();

    public void Subscribe(
        string channel,
        Action<string, string> handler)
    {
        if (!_handlers.ContainsKey(channel))
        {
            _handlers[channel] = handler;
        }
        else
        {
            _handlers[channel] += handler;
        }
    }

    public void Unsubscribe(
        string channel,
        Action<string, string> handler)
    {
        if (_handlers.ContainsKey(channel))
        {
            _handlers[channel] -= handler;
        }
    }

    public void Publish(
        string channel,
        string sender,
        string message)
    {
        if (_handlers.TryGetValue(channel, out var handler))
        {
            handler?.Invoke(sender, message);
        }
    }
}

This EventMediator uses a dictionary of delegate chains keyed by channel name. Participants subscribe to specific channels and receive only messages published to those channels. This is a step beyond the broadcast-only approach -- it introduces topic-based routing.

Here's how participants use it:

using System;
using MediatorPattern.EventDriven;

var mediator = new EventMediator();

void OnBugReport(string sender, string message) =>
    Console.WriteLine(
        $"[BUG HANDLER] {sender}: {message}");

void OnFeatureRequest(string sender, string message) =>
    Console.WriteLine(
        $"[FEATURE HANDLER] {sender}: {message}");

void OnAllMessages(string sender, string message) =>
    Console.WriteLine(
        $"[LOGGER] {sender}: {message}");

mediator.Subscribe("bugs", OnBugReport);
mediator.Subscribe("features", OnFeatureRequest);
mediator.Subscribe("bugs", OnAllMessages);
mediator.Subscribe("features", OnAllMessages);

mediator.Publish("bugs", "Alice",
    "Login page crashes on submit.");
mediator.Publish("features", "Bob",
    "Add dark mode support.");

When Alice publishes a bug report, both OnBugReport and OnAllMessages fire. When Bob publishes a feature request, both OnFeatureRequest and OnAllMessages fire. The mediator handles the routing based on channel subscriptions, and participants are completely decoupled from each other.

This event-driven approach relates closely to the observer design pattern, but there's a key difference. With the observer pattern, the subject (publisher) directly maintains a list of observers. With the mediator pattern, neither the publisher nor the subscriber knows about the other -- the mediator sits between them. The mediator provides a centralized coordination point that the observer pattern doesn't. You can also see similarities with how the command design pattern decouples the invoker from the executor, but commands encapsulate requests as objects while the mediator focuses on routing communication.

MediatR Library Approach

The MediatR library by Jimmy Bogard is the most widely adopted implementation of the mediator pattern in the .NET ecosystem. It provides an in-process messaging infrastructure that routes requests to handlers, enabling a clean separation between sending a request and handling it.

MediatR supports two communication models: request/response (one request, one handler) and notifications (one notification, many handlers). This maps to the mediator pattern's core idea -- components communicate through a mediator instead of referencing each other directly.

Here's what a typical MediatR setup looks like:

using MediatR;

// Define a request and its response
public sealed record GetUserQuery(int UserId)
    : IRequest<UserDto>;

public sealed record UserDto(
    int Id,
    string Name,
    string Email);

// Define the handler
public sealed class GetUserQueryHandler
    : IRequestHandler<GetUserQuery, UserDto>
{
    public Task<UserDto> Handle(
        GetUserQuery request,
        CancellationToken cancellationToken)
    {
        // In a real app, this would query a database
        var user = new UserDto(
            request.UserId,
            "Alice",
            "[email protected]");

        return Task.FromResult(user);
    }
}

The sending code doesn't reference the handler at all:

using MediatR;
using Microsoft.Extensions.DependencyInjection;

// Registration in your DI container
var services = new ServiceCollection();
services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssemblyContaining<
        GetUserQueryHandler>());

var provider = services.BuildServiceProvider();
var mediator = provider.GetRequiredService<IMediator>();

// Sending the request
var user = await mediator.Send(
    new GetUserQuery(42));

Console.WriteLine($"{user.Name} ({user.Email})");

The caller sends a GetUserQuery through IMediator.Send(), and MediatR routes it to the corresponding GetUserQueryHandler. The caller doesn't know the handler exists, and the handler doesn't know who sent the request. This is the mediator pattern in action -- dependency injection wires everything together at startup, and the mediator handles the routing at runtime.

MediatR also supports pipeline behaviors, which let you inject cross-cutting concerns like logging, validation, and performance monitoring into the request pipeline. This is conceptually similar to the chain of responsibility design pattern -- each behavior in the pipeline can inspect or modify the request before passing it to the next behavior.

The key trade-off with MediatR is discoverability. When you call mediator.Send(new GetUserQuery(42)), it's not immediately obvious which handler will execute. You lose the "Go to Definition" navigation that a direct method call provides. In exchange, you gain a clean architecture where handlers are small, focused, and independently testable. Whether this trade-off is worth it depends on the size and complexity of your application.

Understanding how the mediator pattern compares to other design patterns helps you pick the right tool for the problem.

Mediator vs Observer: The observer pattern creates a one-to-many dependency where a subject broadcasts to its observers. The mediator pattern creates a many-to-many coordination hub where no participant knows about any other. Use the observer when one object needs to notify multiple dependents. Use the mediator when multiple objects need to communicate with each other and you want to eliminate the direct references between them.

Mediator vs Facade: The facade pattern provides a simplified interface to a subsystem, but the subsystem classes don't know about the facade. The mediator pattern is known to its participants -- colleagues actively send messages through the mediator. A facade simplifies access; a mediator coordinates interactions.

Mediator vs Command: The command pattern encapsulates a request as an object but doesn't define how commands are routed. The mediator pattern defines the routing. In practice, these patterns work together -- MediatR is built on exactly this combination, where commands (requests) are routed through a mediator to their handlers.

Best Practices and Pitfalls

The mediator pattern is straightforward conceptually, but production implementations require care. Keep these considerations in mind.

Keep the mediator focused on coordination. The mediator should route messages and manage participant interactions -- not implement business logic. If your mediator is making database calls, performing calculations, or enforcing domain rules, those responsibilities belong in the colleague classes or in dedicated services. A bloated mediator defeats the purpose of the pattern.

Avoid creating a God Object. This is the most common pitfall with the mediator pattern. As the system grows, there's a temptation to funnel everything through the mediator. Resist it. If the mediator starts coordinating unrelated concerns, split it into multiple mediators, each responsible for a specific domain of interaction.

Use interfaces for the mediator contract. Always program against the IMediator interface, not the concrete mediator. This makes it easy to substitute different mediator implementations for testing, and it supports the strategy design pattern approach of swapping behavior at runtime.

Handle errors within the mediation pipeline. If one colleague's handler throws an exception during message delivery, decide up front whether that should prevent other colleagues from receiving the message. In most broadcast scenarios, you'll want to catch exceptions per-recipient and log them rather than letting one failure cascade.

Be mindful of debugging complexity. Because communication is indirect, tracing a message from sender to receiver requires following the path through the mediator. Good logging at the mediator level helps. Consider logging every message that flows through the mediator with sender, recipient, and message type information.

Thread safety matters in concurrent scenarios. If colleagues send messages from different threads, the mediator's participant list and routing logic must be thread-safe. Use concurrent collections or synchronization primitives to protect shared state, similar to the concerns you'd face with the state design pattern in multi-threaded environments.

Frequently Asked Questions

What is the mediator design pattern used for?

The mediator design pattern is used to reduce the communication complexity between multiple objects. Instead of objects referencing each other directly, they communicate through a central mediator object. This reduces coupling, makes individual objects easier to reuse, and centralizes interaction logic in one place. Common use cases include chat systems, UI component coordination, workflow engines, and in-process messaging pipelines like MediatR.

How does the mediator pattern reduce coupling?

Without the mediator pattern, N objects that need to communicate can create up to N×(N-1) direct references. The mediator replaces this mesh with N references to a single mediator object. Each colleague depends only on the mediator interface -- not on any other colleague. This means you can add, remove, or modify colleagues without affecting the others, as long as they conform to the mediator's communication contract.

What is the difference between mediator and observer patterns in C#?

The observer pattern creates a one-to-many relationship where a single subject broadcasts notifications to multiple observers. The mediator pattern creates a many-to-many coordination hub where multiple objects communicate through a central mediator. In the observer pattern, the subject knows about its observers (via an interface). In the mediator pattern, colleagues don't know about each other at all -- they only know the mediator. Use the observer for simple event notification. Use the mediator when multiple objects need bidirectional or complex communication coordination.

Is MediatR an implementation of the mediator pattern?

Yes. MediatR implements the mediator pattern as an in-process messaging pipeline. It routes requests (commands and queries) to their corresponding handlers without the sender knowing which handler will process the request. MediatR adds features beyond the classic GoF pattern, including pipeline behaviors for cross-cutting concerns and support for both request/response and notification (publish/subscribe) messaging models. It integrates with .NET's dependency injection through IServiceCollection for handler registration.

When should I avoid the mediator pattern?

Avoid the mediator pattern when the interactions between objects are simple and few. If two objects communicate in a straightforward way, adding a mediator introduces unnecessary indirection. Also avoid it if the mediator would grow into a monolithic God Object that handles too many unrelated coordination responsibilities. If the mediator becomes harder to understand than the direct references it replaced, it's doing more harm than good.

Can the mediator pattern be combined with other design patterns?

Absolutely. The mediator pattern combines naturally with several other patterns. The command pattern pairs with it to encapsulate requests as objects that the mediator routes. The chain of responsibility pattern integrates as a pipeline within the mediator for pre- and post-processing. The adapter pattern helps when colleagues have incompatible interfaces that need translation before they can communicate through the mediator.

How do I test code that uses the mediator pattern?

Testing is one of the mediator pattern's strengths. You can test colleagues in isolation by providing a mock or stub mediator that verifies the messages they send without involving other colleagues. You can test the mediator by providing mock colleagues and verifying that messages are routed correctly. With MediatR, you can test handlers directly by instantiating them and calling their Handle method without needing the MediatR pipeline at all.

Wrapping Up the Mediator Design Pattern in C#

The mediator design pattern in C# is a powerful behavioral pattern for managing complex communication between objects. By centralizing interaction logic in a mediator object, you eliminate direct dependencies between participants and create a system that's easier to understand, extend, and test. Whether you implement it from scratch with custom interfaces, build an event-driven variation with C# delegates, or leverage the MediatR library for in-process messaging, the core principle stays the same: objects talk to the mediator, not to each other.

Start by identifying places in your codebase where multiple objects reference each other to communicate. If those cross-references are making the code hard to follow or preventing you from reusing individual components, the mediator pattern is worth considering. Keep your mediators focused on coordination, watch out for the God Object trap, and use interfaces so you can swap implementations for testing. You can find the mediator pattern alongside other behavioral patterns in the complete list of design patterns.

Factory Method Design Pattern in C#: Complete Guide

Master the Factory Method design pattern in C# with code examples, real-world scenarios, and practical guidance for flexible object creation.

Exploring Examples Of The Mediator Pattern In C#

Let's explore examples of the Mediator Pattern in C#! See how the Mediator Pattern in C# can promote loose coupling, and increase scalability.

Observer vs Mediator Pattern in C#: Key Differences Explained

Compare observer vs mediator pattern in C# with side-by-side code examples, key differences explained, and guidance on when to use each.

An error has occurred. This application may no longer respond until reloaded. Reload