Mediator Design Pattern in C#: Complete Guide for Developers
The Mediator design pattern is one of those design patterns that can transform chaotic, tightly-coupled code into elegant, maintainable architecture. Yet many developers struggle with understanding when and how to implement it effectively. If you’ve ever found yourself wrestling with complex object interactions or wondering whether your components are too tightly coupled, this guide will help you master the Mediator pattern once and for all.
Table of Contents
What is the Mediator Pattern?
The Mediator pattern defines how a set of objects interact with each other by encapsulating their communication logic in a separate mediator object. Instead of objects communicating directly, they communicate through the mediator, which coordinates their interactions and reduces dependencies between communicating objects.
Think of it like an air traffic control tower at an airport. Pilots don’t communicate directly with each other about takeoffs, landings, and flight paths. Instead, they all communicate through the control tower, which coordinates all aircraft movements safely and efficiently.
In software terms, the Mediator pattern promotes loose coupling by keeping objects from referring to each other explicitly, and it centralizes complex communications and control logic between objects.
Why Use the Mediator Pattern?
Reducing Coupling Between Components
The primary benefit of the Mediator pattern is reducing tight coupling between interacting objects. Without a mediator, objects need direct references to each other, creating a web of dependencies that becomes increasingly difficult to manage as your system grows.
Consider a chat application where users can send messages, create groups, and receive notifications. Without a mediator, each user object would need references to all other users, group objects, and notification services. This creates a maintenance nightmare when you need to modify how these interactions work.
Centralizing Complex Control Logic
The Mediator pattern shines when you have complex business rules governing how objects interact. Instead of scattering this logic across multiple classes, you centralize it in the mediator, making it easier to understand, modify, and test.
Improving Reusability
When objects don’t depend directly on each other, they become more reusable. You can easily swap out components or use them in different contexts without worrying about breaking existing relationships.
Understanding the UML Structure
The UML class diagram for the Mediator pattern reveals the elegant simplicity behind this powerful design pattern. Understanding this structure is crucial for implementing the pattern correctly and recognizing when to apply it in your own projects.
Core Components Breakdown
Mediator Interface (IChatMediator) The mediator interface sits at the heart of the pattern, defining the contract for communication between colleagues. In our chat room example, this interface specifies methods like SendMessage(), AddUser(), and RemoveUser(). This abstraction is crucial because it allows colleagues to work with any concrete mediator implementation without being tightly coupled to specific implementations.
Concrete Mediator (ChatRoom) The concrete mediator is where the coordination magic happens. It maintains references to all colleague objects and implements the actual communication logic. Notice how the ChatRoom class has a collection of users (List<User>) and orchestrates message delivery between them. This centralization is what makes the pattern so powerful for managing complex interactions.
Abstract Colleague (User) The abstract colleague class provides a common interface for all objects that will communicate through the mediator. It typically holds a reference to the mediator interface and provides base functionality that all concrete colleagues will need. This abstraction allows the mediator to work with different types of colleagues uniformly.
Concrete Colleagues (ConcreteUser, AdminUser, GuestUser) These are the actual objects that need to communicate with each other. Each concrete colleague has specific behaviors and responsibilities, but they all communicate through the same mediator interface. This design allows for easy extension – you can add new types of users without modifying existing code.
Relationship Analysis
The relationships shown in the UML diagram tell an important story about how the pattern works:
One-to-Many Association: The mediator maintains references to multiple colleagues (1 to many relationship). This allows it to coordinate communication between any number of participants.
Dependency Relationship: Colleagues depend on the mediator interface, not the concrete implementation. This dependency is represented by dashed lines in the UML, indicating that colleagues use the mediator but don’t own it.
Inheritance Hierarchy: The inheritance relationships show how you can extend the pattern with new types of colleagues while maintaining the same communication protocol.
Communication Flow Visualization
The UML diagram illustrates the communication flow that eliminates direct dependencies:
- A colleague calls a method on the mediator (like
SendMessage()) - The mediator processes this request and determines which other colleagues should be notified
- The mediator calls appropriate methods on the target colleagues
- No colleague ever communicates directly with another colleague
This indirect communication is what gives the Mediator pattern its power to reduce coupling and centralize control logic.
Common Misconceptions About the Mediator Pattern
Mediator vs Observer Pattern
Many developers confuse the Mediator pattern with the Observer pattern because both involve decoupling objects. However, they serve different purposes:
- Observer pattern: Defines a one-to-many dependency where observers are notified of state changes
- Mediator pattern: Defines many-to-many relationships where objects communicate through a central mediator
The Observer pattern is about notification, while the Mediator pattern is about coordination and control.
When NOT to Use Mediator
The Mediator pattern isn’t always the right choice. Avoid it when:
- You have simple, direct relationships between just two objects
- The communication logic is straightforward and unlikely to change
- You’re dealing with performance-critical code where the mediator overhead matters
- The mediator would become a “god object” handling too many responsibilities
Real-World Scenarios Where Mediator Excels
User Interface Components
GUI applications are perfect candidates for the Mediator pattern. Consider a dialog box with multiple controls that need to interact: text boxes, buttons, checkboxes, and dropdown lists. The mediator coordinates their interactions based on user actions and business rules.
public class DialogMediator
{
private TextBox _nameTextBox;
private Button _submitButton;
private CheckBox _agreementCheckBox;
public void RegisterComponents(TextBox nameBox, Button submitBtn, CheckBox agreementBox)
{
_nameTextBox = nameBox;
_submitButton = submitBtn;
_agreementCheckBox = agreementBox;
// Set up event handlers
_nameTextBox.TextChanged += OnNameChanged;
_agreementCheckBox.CheckedChanged += OnAgreementChanged;
}
private void OnNameChanged(object sender, EventArgs e)
{
UpdateSubmitButtonState();
}
private void OnAgreementChanged(object sender, EventArgs e)
{
UpdateSubmitButtonState();
}
private void UpdateSubmitButtonState()
{
_submitButton.Enabled = !string.IsNullOrWhiteSpace(_nameTextBox.Text)
&& _agreementCheckBox.Checked;
}
}
Workflow Management Systems
In business applications, you often have complex workflows where different steps depend on each other. A mediator can coordinate these steps, ensuring proper sequencing and handling exceptions.
Game Development
Game engines frequently use mediators to coordinate interactions between game objects, handle collision detection, manage game state transitions, and coordinate audio-visual effects.
Step-by-Step Implementation Guide
Now that we understand the structure, let’s walk through implementing the Mediator pattern step by step. This detailed guide will help you avoid common implementation mistakes and create maintainable, extensible code.
Step 1: Define the Mediator Interface
Start by defining what communication operations your mediator will support. Keep the interface focused and cohesive – it should represent a single responsibility domain.
public interface IChatMediator
{
void SendMessage(string message, User sender);
void SendPrivateMessage(string message, User sender, User recipient);
void AddUser(User user);
void RemoveUser(User user);
void NotifyUserJoined(User user);
void NotifyUserLeft(User user);
}
- Include all necessary communication operations
- Keep methods focused on coordination, not business logic
- Consider future extensibility when designing the interface
- Use meaningful parameter names and types
Step 2: Create the Abstract Colleague Base Class
The abstract colleague provides common functionality and ensures all colleagues can work with the mediator consistently.
public abstract class User
{
protected IChatMediator _mediator;
public string Name { get; protected set; }
public string Id { get; protected set; }
public DateTime JoinedAt { get; protected set; }
protected User(string name)
{
Name = name;
Id = Guid.NewGuid().ToString();
JoinedAt = DateTime.UtcNow;
}
public virtual void SetMediator(IChatMediator mediator)
{
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
}
// Abstract methods that concrete colleagues must implement
public abstract void SendMessage(string message);
public abstract void ReceiveMessage(string message, string senderName);
public abstract void ReceivePrivateMessage(string message, string senderName);
// Virtual methods that can be overridden
public virtual void OnUserJoined(string userName) { }
public virtual void OnUserLeft(string userName) { }
}
- Protect the mediator field to prevent external modification
- Include common properties that all colleagues will need
- Use abstract methods for required functionality
- Use virtual methods for optional behavior that subclasses can override
Step 3: Implement Concrete Colleagues
Each concrete colleague should focus on its specific behavior while delegating communication to the mediator.
public class RegularUser : User
{
public RegularUser(string name) : base(name) { }
public override void SendMessage(string message)
{
if (string.IsNullOrWhiteSpace(message))
throw new ArgumentException("Message cannot be empty", nameof(message));
Console.WriteLine($"{Name} sends: {message}");
_mediator.SendMessage(message, this);
}
public override void ReceiveMessage(string message, string senderName)
{
Console.WriteLine($"{Name} received: {message} from {senderName}");
}
public override void ReceivePrivateMessage(string message, string senderName)
{
Console.WriteLine($"{Name} received private message: {message} from {senderName}");
}
}
public class AdminUser : User
{
public AdminUser(string name) : base(name) { }
public override void SendMessage(string message)
{
Console.WriteLine($"[ADMIN] {Name} sends: {message}");
_mediator.SendMessage($"[ADMIN] {message}", this);
}
public void KickUser(User user)
{
Console.WriteLine($"[ADMIN] {Name} kicked {user.Name}");
_mediator.RemoveUser(user);
}
public override void ReceiveMessage(string message, string senderName)
{
Console.WriteLine($"[ADMIN] {Name} received: {message} from {senderName}");
}
public override void ReceivePrivateMessage(string message, string senderName)
{
Console.WriteLine($"[ADMIN] {Name} received private: {message} from {senderName}");
}
}
- Each colleague has a single, well-defined responsibility
- Input validation is performed at the colleague level
- Special behaviors (like admin actions) are encapsulated in specific colleague types
- All communication goes through the mediator
Step 4: Implement the Concrete Mediator
The concrete mediator contains the coordination logic and maintains colleague state.
public class ChatRoom : IChatMediator
{
private readonly List _users;
private readonly Dictionary _userLookup;
private readonly ILogger _logger;
public string RoomName { get; private set; }
public int MaxUsers { get; private set; }
public ChatRoom(string roomName, int maxUsers = 100, ILogger logger = null)
{
RoomName = roomName ?? throw new ArgumentNullException(nameof(roomName));
MaxUsers = maxUsers;
_users = new List();
_userLookup = new Dictionary();
_logger = logger;
}
public void AddUser(User user)
{
if (user == null)
throw new ArgumentNullException(nameof(user));
if (_users.Count >= MaxUsers)
throw new InvalidOperationException("Chat room is full");
if (_userLookup.ContainsKey(user.Id))
throw new InvalidOperationException("User already in room");
_users.Add(user);
_userLookup[user.Id] = user;
user.SetMediator(this);
_logger?.LogInformation($"User {user.Name} joined {RoomName}");
NotifyUserJoined(user);
}
public void RemoveUser(User user)
{
if (user == null) return;
if (_users.Remove(user))
{
_userLookup.Remove(user.Id);
_logger?.LogInformation($"User {user.Name} left {RoomName}");
NotifyUserLeft(user);
}
}
public void SendMessage(string message, User sender)
{
if (sender == null || !_userLookup.ContainsKey(sender.Id))
throw new InvalidOperationException("Sender not in chat room");
var recipients = _users.Where(u => u.Id != sender.Id).ToList();
foreach (var recipient in recipients)
{
try
{
recipient.ReceiveMessage(message, sender.Name);
}
catch (Exception ex)
{
_logger?.LogError(ex, $"Error delivering message to {recipient.Name}");
}
}
}
public void SendPrivateMessage(string message, User sender, User recipient)
{
if (sender == null || recipient == null)
throw new ArgumentNullException("Sender and recipient cannot be null");
if (!_userLookup.ContainsKey(sender.Id) || !_userLookup.ContainsKey(recipient.Id))
throw new InvalidOperationException("Both users must be in the chat room");
try
{
recipient.ReceivePrivateMessage(message, sender.Name);
}
catch (Exception ex)
{
_logger?.LogError(ex, $"Error delivering private message to {recipient.Name}");
}
}
public void NotifyUserJoined(User user)
{
var message = $"{user.Name} joined the chat";
var otherUsers = _users.Where(u => u.Id != user.Id);
foreach (var otherUser in otherUsers)
{
try
{
otherUser.OnUserJoined(user.Name);
}
catch (Exception ex)
{
_logger?.LogError(ex, $"Error notifying {otherUser.Name} about user join");
}
}
}
public void NotifyUserLeft(User user)
{
var otherUsers = _users.Where(u => u.Id != user.Id);
foreach (var otherUser in otherUsers)
{
try
{
otherUser.OnUserLeft(user.Name);
}
catch (Exception ex)
{
_logger?.LogError(ex, $"Error notifying {otherUser.Name} about user leave");
}
}
}
}
- Error handling: Each operation includes proper exception handling
- Validation: Input validation prevents invalid states
- Logging: Integration with logging framework for debugging
- Performance optimization: Dictionary lookup for fast user access
- Resource management: Proper cleanup when users leave
Step 5: Client Usage and Configuration
Finally, show how to use the mediator in client code:
public class ChatApplication
{
public static void Main()
{
// Create the mediator
var chatRoom = new ChatRoom("General Chat", maxUsers: 50);
// Create colleagues
var alice = new RegularUser("Alice");
var bob = new RegularUser("Bob");
var admin = new AdminUser("Administrator");
// Add users to the chat room
chatRoom.AddUser(alice);
chatRoom.AddUser(bob);
chatRoom.AddUser(admin);
// Users communicate through the mediator
alice.SendMessage("Hello everyone!");
bob.SendMessage("Hi Alice!");
admin.SendMessage("Welcome to the chat room!");
// Private messaging
chatRoom.SendPrivateMessage("How are you?", alice, bob);
// Admin actions
var troublemaker = new RegularUser("Troublemaker");
chatRoom.AddUser(troublemaker);
admin.KickUser(troublemaker);
}
}
This implementation demonstrates several key principles:
- Separation of concerns: Each class has a single responsibility
- Loose coupling: Colleagues only know about the mediator interface
- Extensibility: New colleague types can be added easily
- Error handling: Robust error handling throughout the system
- Maintainability: Clear, readable code with proper abstractions
Testing Strategies for Mediated Components
Unit Testing Individual Components
When testing components that use the Mediator pattern, create mock mediators to isolate the component under test:
[Test]
public void User_SendMessage_CallsMediator()
{
// Arrange
var mockMediator = new Mock();
var user = new ConcreteUser("TestUser");
user.SetMediator(mockMediator.Object);
// Act
user.SendMessage("Hello World");
// Assert
mockMediator.Verify(m => m.SendMessage("Hello World", user), Times.Once);
}
Integration Testing the Mediator
Test the mediator’s coordination logic by setting up multiple components and verifying their interactions:
[Test]
public void ChatRoom_SendMessage_DeliveredToAllOtherUsers()
{
// Arrange
var chatRoom = new ChatRoom();
var user1 = new ConcreteUser("Alice");
var user2 = new ConcreteUser("Bob");
chatRoom.AddUser(user1);
chatRoom.AddUser(user2);
// Act & Assert
// Test message delivery logic
}
Mocking Strategies
Use dependency injection to make your mediators testable. Inject dependencies rather than creating them directly within the mediator.
Performance Considerations
When Mediator Overhead Matters
The Mediator pattern introduces an additional layer of indirection, which can impact performance in high-throughput scenarios. Consider the trade-off between maintainability and performance:
- High-frequency operations: Direct communication might be more appropriate
- Complex business logic: The mediator’s benefits usually outweigh the performance cost
- Scalability requirements: Profile your application to identify actual bottlenecks
Optimization Techniques
To minimize performance impact:
- Lazy initialization: Only create expensive resources when needed
- Caching: Store frequently accessed data in the mediator
- Async operations: Use async/await for I/O-bound operations
- Batch processing: Group related operations together
Common Pitfalls and How to Avoid Them
The God Object Anti-Pattern
One of the biggest risks with the Mediator pattern is creating a mediator that tries to handle too many responsibilities. This violates the Single Responsibility Principle and makes the mediator difficult to maintain.
Solution: Create multiple specialized mediators for different domains or use a hierarchical mediator structure.
Circular Dependencies
Be careful not to create circular dependencies where the mediator depends on components that depend on the mediator.
Solution: Use interfaces and dependency injection to break circular dependencies.
Over-Engineering Simple Interactions
Don’t use the Mediator pattern for simple, direct relationships between two objects.
Solution: Start with simple direct communication and refactor to use a mediator when complexity increases.
Mediator Pattern vs Other Patterns
Mediator vs Facade
- Facade: Provides a simplified interface to a complex subsystem
- Mediator: Coordinates communication between objects
Mediator vs Command
- Command: Encapsulates a request as an object
- Mediator: Coordinates multiple commands and their interactions
Mediator vs Observer
- Observer: One-to-many notifications
- Mediator: Many-to-many coordination
Best Practices for Mediator Implementation
Design Guidelines
- Keep mediators focused: Each mediator should handle a specific domain
- Use interfaces: Define clear contracts for mediator interactions
- Minimize mediator knowledge: Components should know as little as possible about the mediator’s internal workings
- Document interaction patterns: Clearly document how components interact through the mediator
Code Organization
Structure your code to clearly separate mediator logic from business logic:
// Good: Mediator focuses on coordination
public class OrderProcessingMediator
{
public async Task ProcessOrder(Order order)
{
await _inventoryService.ReserveItems(order.Items);
await _paymentService.ProcessPayment(order.Payment);
await _shippingService.ScheduleShipment(order);
await _notificationService.SendConfirmation(order.Customer);
}
}
Error Handling
Implement proper error handling in your mediators to ensure system stability:
public class RobustMediator
{
public async Task ProcessRequest(Request request)
{
try
{
// Process request
return Result.Success();
}
catch (ValidationException ex)
{
return Result.Failure(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error in mediator");
return Result.Failure("An unexpected error occurred");
}
}
}
Advanced Mediator Patterns
Hierarchical Mediators
For complex systems, you might need multiple levels of mediators:
public class ApplicationMediator
{
private readonly UIMediator _uiMediator;
private readonly BusinessMediator _businessMediator;
private readonly DataMediator _dataMediator;
public ApplicationMediator(UIMediator ui, BusinessMediator business, DataMediator data)
{
_uiMediator = ui;
_businessMediator = business;
_dataMediator = data;
}
public async Task HandleUserAction(UserAction action)
{
var businessResult = await _businessMediator.ProcessAction(action);
await _dataMediator.PersistResult(businessResult);
await _uiMediator.UpdateUI(businessResult);
}
}
Event-Driven Mediators
Combine the Mediator pattern with event-driven architecture for more flexible systems:
public class EventDrivenMediator
{
private readonly IEventBus _eventBus;
public EventDrivenMediator(IEventBus eventBus)
{
_eventBus = eventBus;
}
public async Task HandleCommand(ICommand command)
{
var events = await ProcessCommand(command);
foreach (var @event in events)
{
await _eventBus.PublishAsync(@event);
}
}
}
Conclusion
The Mediator pattern is a powerful tool for managing complex object interactions and reducing coupling in your applications. When used appropriately, it can significantly improve your code’s maintainability, testability, and flexibility.
Remember these key points:
- Use the Mediator pattern when you have complex interactions between multiple objects
- Avoid over-engineering simple two-object relationships
- Keep your mediators focused on coordination, not business logic
- Test your mediators thoroughly, including both unit and integration tests
- Consider performance implications in high-throughput scenarios
The Mediator pattern shines in scenarios like user interface coordination, workflow management, and game development. By centralizing interaction logic, you create more maintainable and flexible systems that can adapt to changing requirements.
Start small with simple mediator implementations, and gradually evolve them as your system’s complexity grows. With practice, you’ll develop an intuition for when and how to apply this pattern effectively in your own projects.
Whether you’re building a simple chat application or a complex enterprise system, the Mediator pattern can help you create cleaner, more maintainable code that stands the test of time.