How to Implement Mediator Pattern in C#: Step-by-Step Guide
Objects that talk directly to each other create a web of dependencies that gets harder to maintain as your codebase grows. The mediator pattern solves this by introducing a central coordinator that handles all communication between components -- so each component only knows about the mediator, not about the other components. If you want to implement mediator pattern in C#, this guide walks you through the entire process from defining the mediator interface to wiring components together and writing unit tests.
The mediator pattern is a behavioral design pattern that promotes loose coupling by preventing objects from referring to each other explicitly. Instead of ten components each holding references to nine others, every component holds a single reference to the mediator. The mediator encapsulates the interaction logic and routes messages between colleagues. This keeps individual components focused on their own responsibilities while the mediator orchestrates how they collaborate. If you're exploring how design patterns relate to each other, you'll find that the mediator pattern pairs well with the observer pattern -- the observer defines notification mechanics, while the mediator pattern centralizes coordination logic.
In this step-by-step guide, we'll build a chat room system using the mediator pattern. We'll define the mediator interface, create a concrete mediator, build colleague components, wire everything together, and write unit tests. By the end, you'll understand how the mediator pattern reduces coupling and know how to apply it to your own projects.
Prerequisites
Before diving in, make sure you're comfortable with these fundamentals:
- C# interfaces and classes: You'll define mediator and colleague interfaces and implement multiple concrete classes. Understanding how interfaces enforce contracts is essential.
- Composition over inheritance: When you implement the mediator pattern, components hold a reference to the mediator through composition rather than inheriting from it. This keeps components decoupled and swappable.
- Dependency injection basics: The final steps cover registering mediators and colleagues with IServiceCollection. Familiarity with service registration will help.
- .NET 8 or later: The code examples use modern C# syntax. Any recent .NET SDK works.
Step 1: Define the IMediator Interface
The first step to implement the mediator pattern in C# is defining the mediator interface. This interface declares how colleagues send messages through the mediator. The mediator receives a message from one colleague and decides how to route it to the others.
public interface IChatMediator
{
void SendMessage(
string message,
IChatColleague sender);
void RegisterColleague(IChatColleague colleague);
}
Two methods form the core contract:
- SendMessage accepts a message and a reference to the sender. The mediator uses the sender reference to avoid echoing the message back to the originator and to apply any routing logic.
- RegisterColleague lets new participants join the mediated group. Some mediator implementations skip explicit registration and discover colleagues through dependency injection, but explicit registration gives you fine-grained control over which components participate.
The interface is intentionally small. A lean contract means less coupling between the mediator and its colleagues. This aligns with the principle of inversion of control -- colleagues depend on the IChatMediator abstraction rather than on each other or on a concrete mediator class.
Step 2: Define the Colleague Base Class
Next, we need a base type for all components that communicate through the mediator. The colleague base class (or interface) holds a reference to the mediator and defines how colleagues send and receive messages.
public interface IChatColleague
{
string Name { get; }
void ReceiveMessage(
string message,
string senderName);
}
public abstract class ChatColleagueBase : IChatColleague
{
protected IChatMediator Mediator { get; }
public string Name { get; }
protected ChatColleagueBase(
IChatMediator mediator,
string name)
{
Mediator = mediator
?? throw new ArgumentNullException(
nameof(mediator));
Name = name
?? throw new ArgumentNullException(
nameof(name));
}
public void Send(string message)
{
Mediator.SendMessage(message, this);
}
public abstract void ReceiveMessage(
string message,
string senderName);
}
There are a few design choices worth explaining:
- We define
IChatColleagueas a separate interface so the mediator can reference colleagues without depending on the abstract base class. This gives you flexibility -- you could have colleagues that skip the base class entirely and implementIChatColleaguedirectly. - The
ChatColleagueBaseabstract class holds the mediator reference and provides aSendconvenience method. Subclasses callSendinstead of interacting with the mediator directly, which reduces boilerplate. - The
ReceiveMessagemethod is abstract, forcing each concrete colleague to define its own reaction to incoming messages. This is similar to how the strategy pattern lets you swap behavior through an interface -- each colleague encapsulates its own notification-handling logic.
Step 3: Create the Concrete Mediator
With the interfaces defined, we can build the concrete mediator. The ChatRoom class maintains a list of registered colleagues and routes messages between them. This is where the mediator pattern's coordination logic lives.
public class ChatRoom : IChatMediator
{
private readonly List<IChatColleague> _colleagues;
public ChatRoom()
{
_colleagues = new List<IChatColleague>();
}
public void RegisterColleague(
IChatColleague colleague)
{
if (colleague == null)
{
throw new ArgumentNullException(
nameof(colleague));
}
if (!_colleagues.Contains(colleague))
{
_colleagues.Add(colleague);
}
}
public void SendMessage(
string message,
IChatColleague sender)
{
foreach (var colleague in _colleagues.ToArray())
{
if (colleague != sender)
{
colleague.ReceiveMessage(
message,
sender.Name);
}
}
}
}
A few things to notice:
- Duplicate prevention:
RegisterColleaguechecks whether the colleague is already in the list before adding it. This prevents double-delivery of messages. - Snapshot iteration:
SendMessageiterates over_colleagues.ToArray()instead of the list directly. This creates a snapshot that protects againstInvalidOperationExceptionif a colleague modifies the list during notification -- for example, by unregistering itself in response to a message. - Sender exclusion: The
if (colleague != sender)check prevents echoing a message back to the colleague that sent it. This is a common mediator pattern convention that keeps the communication flow intuitive.
The mediator encapsulates all the interaction rules in one place. If you need to change how messages are routed -- for example, adding message filtering, priority routing, or logging -- you modify only the mediator class. The colleagues remain untouched. This centralization of interaction logic is what distinguishes the mediator pattern from patterns like the facade pattern, which simplifies a subsystem's interface but doesn't manage bidirectional communication between peers.
Step 4: Build Concrete Colleagues
Now let's create two concrete colleague implementations to implement the mediator pattern end to end. Each colleague receives the same message but can react differently.
Standard User
This colleague prints received messages to the console:
public class StandardUser : ChatColleagueBase
{
private readonly List<string> _messageLog;
public IReadOnlyList<string> MessageLog
=> _messageLog;
public StandardUser(
IChatMediator mediator,
string name)
: base(mediator, name)
{
_messageLog = new List<string>();
}
public override void ReceiveMessage(
string message,
string senderName)
{
var formatted =
$"[{Name}] received from " +
$"{senderName}: {message}";
_messageLog.Add(formatted);
Console.WriteLine(formatted);
}
}
Bot User
This colleague auto-responds to messages containing specific keywords:
public class BotUser : ChatColleagueBase
{
private readonly Dictionary<string, string>
_autoResponses;
private readonly List<string> _messageLog;
public IReadOnlyList<string> MessageLog
=> _messageLog;
public BotUser(
IChatMediator mediator,
string name,
Dictionary<string, string> autoResponses)
: base(mediator, name)
{
_autoResponses = autoResponses
?? throw new ArgumentNullException(
nameof(autoResponses));
_messageLog = new List<string>();
}
public override void ReceiveMessage(
string message,
string senderName)
{
var formatted =
$"[{Name}] received from " +
$"{senderName}: {message}";
_messageLog.Add(formatted);
Console.WriteLine(formatted);
foreach (var kvp in _autoResponses)
{
if (message.Contains(
kvp.Key,
StringComparison.OrdinalIgnoreCase))
{
Send(kvp.Value);
break;
}
}
}
}
Both colleagues extend ChatColleagueBase and implement ReceiveMessage, but their behavior is entirely different. The StandardUser simply logs and displays messages. The BotUser checks incoming messages against a dictionary of keywords and sends automated replies through the mediator. Notice that the BotUser calls Send (inherited from the base class) to dispatch its auto-response -- it never communicates directly with other colleagues. All messages flow through the mediator. This demonstrates the mediator pattern's core benefit: colleagues remain independent of each other, and all coordination flows through a single point.
Step 5: Wire Everything Together
Let's bring the pieces together. We'll create the mediator, register colleagues, and send a few messages to see the mediator pattern in action:
var chatRoom = new ChatRoom();
var alice = new StandardUser(chatRoom, "Alice");
var bob = new StandardUser(chatRoom, "Bob");
var autoResponses = new Dictionary<string, string>
{
{ "help", "I can assist you! Type a command." },
{ "hello", "Hi there! Welcome to the chat." }
};
var helpBot = new BotUser(
chatRoom,
"HelpBot",
autoResponses);
chatRoom.RegisterColleague(alice);
chatRoom.RegisterColleague(bob);
chatRoom.RegisterColleague(helpBot);
Console.WriteLine("--- Alice says hello ---");
alice.Send("hello everyone!");
Console.WriteLine();
Console.WriteLine("--- Bob asks for help ---");
bob.Send("Can someone help me?");
Console.WriteLine();
Console.WriteLine("--- Alice sends a regular message ---");
alice.Send("How is the project going?");
Running this produces output like:
--- Alice says hello ---
[Bob] received from Alice: hello everyone!
[HelpBot] received from Alice: hello everyone!
[Alice] received from HelpBot: Hi there! Welcome to the chat.
[Bob] received from HelpBot: Hi there! Welcome to the chat.
--- Bob asks for help ---
[Alice] received from Bob: Can someone help me?
[HelpBot] received from Bob: Can someone help me?
[Alice] received from HelpBot: I can assist you! Type a command.
[Bob] received from HelpBot: I can assist you! Type a command.
--- Alice sends a regular message ---
[Bob] received from Alice: How is the project going?
[HelpBot] received from Alice: How is the project going?
Notice how the HelpBot auto-responds to messages containing "hello" and "help", and those responses flow back through the mediator to all other participants. No colleague knows about any other colleague directly -- the ChatRoom mediator handles all routing. This is the mediator pattern working as designed.
Step 6: Register with Dependency Injection
For production applications, you'll want to register the mediator and its colleagues with your DI container. Here's how to wire the mediator pattern into an IServiceCollection:
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddSingleton<IChatMediator, ChatRoom>();
services.AddTransient<StandardUser>(sp =>
{
var mediator = sp
.GetRequiredService<IChatMediator>();
var user = new StandardUser(
mediator,
"DI-User");
mediator.RegisterColleague(user);
return user;
});
var provider = services.BuildServiceProvider();
var mediator = provider
.GetRequiredService<IChatMediator>();
var user = provider
.GetRequiredService<StandardUser>();
user.Send("Hello from DI!");
The mediator is registered as a singleton so that all colleagues share the same instance. Each colleague resolves the mediator from the container and registers itself during construction. This approach lets you add new colleague types by adding DI registrations without modifying the mediator or existing colleagues.
Step 7: Unit Testing the Mediator Pattern
Testing the mediator pattern is straightforward because the mediator and colleagues interact through well-defined interfaces. You can test the mediator's routing logic, individual colleague behavior, and the integration between them.
Testing Message Routing
This test verifies that the mediator delivers messages to all colleagues except the sender:
using Xunit;
public class ChatRoomTests
{
[Fact]
public void SendMessage_DeliversToAll_ExceptSender()
{
// Arrange
var chatRoom = new ChatRoom();
var alice = new StandardUser(
chatRoom, "Alice");
var bob = new StandardUser(
chatRoom, "Bob");
var charlie = new StandardUser(
chatRoom, "Charlie");
chatRoom.RegisterColleague(alice);
chatRoom.RegisterColleague(bob);
chatRoom.RegisterColleague(charlie);
// Act
alice.Send("Test message");
// Assert
Assert.Empty(alice.MessageLog);
Assert.Single(bob.MessageLog);
Assert.Single(charlie.MessageLog);
Assert.Contains(
"Alice",
bob.MessageLog[0]);
Assert.Contains(
"Test message",
charlie.MessageLog[0]);
}
[Fact]
public void RegisterColleague_PreventsDuplicates()
{
// Arrange
var chatRoom = new ChatRoom();
var alice = new StandardUser(
chatRoom, "Alice");
var bob = new StandardUser(
chatRoom, "Bob");
chatRoom.RegisterColleague(alice);
chatRoom.RegisterColleague(alice);
chatRoom.RegisterColleague(bob);
// Act
alice.Send("Duplicate test");
// Assert
Assert.Single(bob.MessageLog);
}
}
Testing Bot Auto-Responses
This test confirms that the BotUser triggers auto-responses through the mediator:
public class BotUserTests
{
[Fact]
public void ReceiveMessage_WithKeyword_SendsAutoResponse()
{
// Arrange
var chatRoom = new ChatRoom();
var alice = new StandardUser(
chatRoom, "Alice");
var bot = new BotUser(
chatRoom,
"TestBot",
new Dictionary<string, string>
{
{ "help", "Here to help!" }
});
chatRoom.RegisterColleague(alice);
chatRoom.RegisterColleague(bot);
// Act
alice.Send("I need help please");
// Assert
Assert.Single(alice.MessageLog);
Assert.Contains(
"Here to help!",
alice.MessageLog[0]);
}
[Fact]
public void ReceiveMessage_WithoutKeyword_NoAutoResponse()
{
// Arrange
var chatRoom = new ChatRoom();
var alice = new StandardUser(
chatRoom, "Alice");
var bot = new BotUser(
chatRoom,
"TestBot",
new Dictionary<string, string>
{
{ "help", "Here to help!" }
});
chatRoom.RegisterColleague(alice);
chatRoom.RegisterColleague(bot);
// Act
alice.Send("Good morning");
// Assert
Assert.Empty(alice.MessageLog);
}
}
The MessageLog property on each colleague makes assertions straightforward -- you can verify exactly which messages were received and in what order. This avoids the need for mocking frameworks in most mediator pattern tests. If you want to test the mediator in complete isolation, you can create a stub colleague that implements IChatColleague and records all received messages without any side effects.
Common Mistakes to Avoid
Even experienced developers hit these pitfalls when they first implement the mediator pattern in C#. Knowing them ahead of time saves debugging hours.
God mediator anti-pattern: The most common mistake is stuffing too much logic into the mediator. The mediator should coordinate communication between colleagues -- it should not contain business logic that belongs in the colleagues themselves. If your mediator class grows to hundreds of lines, that's a signal you're centralizing too much. Keep the mediator focused on routing and let colleagues own their domain logic.
Circular message loops: If a colleague sends a message in response to receiving one (like our BotUser), and the response triggers another response, you can end up in an infinite loop. Guard against this with a recursion depth counter, a "processing" flag on the mediator, or by designing auto-response keywords so they don't trigger each other.
Tight coupling to the concrete mediator: If colleagues reference ChatRoom instead of IChatMediator, you lose the ability to swap mediator implementations for testing or different runtime scenarios. Always depend on the abstraction. This is the same principle behind the bridge pattern -- separate the abstraction from the implementation.
Forgetting thread safety: If multiple threads send messages simultaneously, the internal List<T> in the mediator can corrupt. For concurrent scenarios, use a ConcurrentBag<T> or wrap access with a lock statement. The snapshot pattern (.ToArray() before iteration) helps with concurrent modification during notification but does not protect against concurrent writes to the list.
Skipping colleague registration: If a colleague is constructed with a mediator reference but never registered via RegisterColleague, it can send messages but never receives them. This creates confusing one-way communication bugs. Consider registering colleagues automatically in the base class constructor to prevent this.
Mediator Pattern vs Related Patterns
Understanding how the mediator pattern relates to other patterns helps you pick the right tool for the job.
The mediator pattern and the observer pattern both decouple components, but they do it differently. The observer pattern defines a one-to-many notification model where subjects broadcast to all subscribers. The mediator pattern centralizes many-to-many communication through a single coordinator. Use the observer pattern when one source pushes updates to many listeners. Use the mediator pattern when multiple peers need to communicate with each other without direct references.
The facade pattern also provides a single point of contact, but it simplifies a subsystem's interface for external callers. The mediator pattern manages internal communication between peers within a subsystem. A facade is one-directional (callers talk to the facade); a mediator is bidirectional (colleagues talk to each other through the mediator).
The command pattern encapsulates requests as objects, which pairs well with the mediator pattern. You can route command objects through a mediator to decouple the sender from the handler -- this is the foundation of libraries like MediatR.
Frequently Asked Questions
What is the mediator pattern and when should I use it?
The mediator pattern defines an object that encapsulates how a set of objects interact. Instead of components communicating directly with each other, they communicate through a central mediator. Use the mediator pattern when you have a group of objects with complex interdependencies that make the system hard to understand and modify. Common examples include chat systems, air traffic control systems, form validation coordinators, and UI component orchestration. If you find yourself with a tangle of cross-references between objects, the mediator pattern can untangle them.
How is the mediator pattern different from MediatR?
The mediator pattern is a design pattern -- a general solution concept. MediatR is a specific .NET library that implements a request/response flavor of the mediator pattern using IRequest<T> and IRequestHandler<TRequest, TResponse> interfaces. MediatR focuses on in-process message dispatching with a pipeline architecture, while the mediator pattern as described by the Gang of Four emphasizes coordinating communication between peer objects. You can implement the mediator pattern without MediatR, and understanding the underlying pattern makes MediatR easier to use effectively.
Can I use the mediator pattern with dependency injection?
Yes. Register the mediator as a singleton in your DI container so all colleagues share the same instance. Register colleagues as transient or scoped services that receive the mediator through constructor injection. Each colleague can register itself with the mediator during construction, or you can register colleagues explicitly after resolution. DI makes the mediator pattern particularly clean because adding new colleagues requires only a new registration -- no existing code changes.
Does the mediator pattern violate the single responsibility principle?
It depends on how you implement it. A well-designed mediator has one responsibility: coordinating communication between colleagues. If the mediator starts containing business logic, validation rules, or data transformation, it's doing too much and becomes a "god object." Keep the mediator focused on routing and let colleagues own their domain-specific behavior. If routing logic becomes complex, consider breaking the mediator into smaller, specialized mediators for different interaction groups.
How do I handle errors in the mediator pattern?
Wrap each colleague notification in a try-catch block inside the mediator's SendMessage method to prevent one failing colleague from blocking message delivery to the others. Log the error and continue iterating. For more structured error handling, define an OnError method on the colleague interface that the mediator calls when a colleague throws during message receipt. This approach is similar to how the IObserver<T> interface handles errors in the observer pattern.
Is the mediator pattern suitable for distributed systems?
The classic mediator pattern works within a single process. For distributed systems, you'd typically use a message broker (like RabbitMQ or Azure Service Bus) that provides similar decoupling across process and machine boundaries. The concepts transfer -- components send messages to a central coordinator that routes them to the right recipients -- but the implementation uses network protocols instead of in-memory method calls.
How do I test mediator pattern code without coupling tests to all colleagues?
Create lightweight stub implementations of IChatColleague that record received messages in a list. Register these stubs with the mediator and assert against their recorded messages. This isolates the mediator's routing logic from the behavior of concrete colleagues. For testing individual colleagues, pass a mock or stub mediator that captures sent messages without routing them, letting you verify the colleague's behavior in isolation.

