BrandGhost
Mediator Pattern Best Practices in C#: Code Organization and Maintainability

Mediator Pattern Best Practices in C#: Code Organization and Maintainability

Mediator Pattern Best Practices in C#: Code Organization and Maintainability

You know what the mediator pattern does -- it centralizes communication between objects so they don't reference each other directly. You've built a mediator, registered some colleagues, and watched messages flow through a central hub. But getting the basic mechanics working is only the beginning. Mediator pattern best practices in C# address the hard part: keeping your mediator focused, preventing it from becoming a god object, handling errors in mediated communication, and structuring code so that adding new colleagues doesn't require touching every existing class.

This guide covers the practical decisions that determine whether your mediator pattern architecture stays clean or collapses under its own weight. We'll work through single-responsibility mediators, programming to interfaces, avoiding the god mediator anti-pattern, testing strategies, knowing when to split mediators, error handling, and performance considerations.

Define Interfaces for the Mediator and Colleagues

The foundation of every maintainable mediator pattern implementation in C# is programming to interfaces rather than concrete types. When colleagues depend on a concrete mediator class, you've created tight coupling -- exactly the problem the mediator pattern is supposed to solve. Define an IMediator interface that colleagues reference, and define an IColleague interface that the mediator works with.

Here's a clean interface structure:

using System;

public interface IMediator
{
    void Notify(
        IColleague sender,
        string eventName);
}

public interface IColleague
{
    IMediator Mediator { get; set; }
}

And a base class that provides the common wiring:

using System;

public abstract class ColleagueBase : IColleague
{
    public IMediator Mediator { get; set; }

    protected ColleagueBase(IMediator mediator)
    {
        Mediator = mediator
            ?? throw new ArgumentNullException(
                nameof(mediator));
    }
}

This mediator pattern best practice in C# gives you two advantages. First, you can swap mediator implementations in tests without modifying colleague code. Second, you can introduce new colleague types without changing the mediator interface. When registering these with dependency injection via IServiceCollection, you register the interface, not the concrete type -- which aligns with inversion of control principles.

A common mistake is skipping the interface step because the application only has one mediator. But "only one" mediator is a temporary state. Testing alone demands a second implementation -- a mock or stub. Always start with the interface.

Keep Each Mediator Focused on One Domain

A mediator should coordinate a single bounded context, not serve as a universal message bus for the entire application. When a single mediator handles order processing, user authentication, and notification routing, you've built a god object with a different name. This is one of the most critical mediator pattern best practices in C# to internalize.

Here's the anti-pattern -- a mediator that coordinates everything:

using System;

// Bad: one mediator handling unrelated concerns
public class ApplicationMediator : IMediator
{
    private readonly OrderForm _orderForm;
    private readonly PaymentProcessor _payment;
    private readonly UserLogin _login;
    private readonly EmailNotifier _email;
    private readonly InventoryTracker _inventory;

    public ApplicationMediator(
        OrderForm orderForm,
        PaymentProcessor payment,
        UserLogin login,
        EmailNotifier email,
        InventoryTracker inventory)
    {
        _orderForm = orderForm;
        _payment = payment;
        _login = login;
        _email = email;
        _inventory = inventory;
    }

    public void Notify(
        IColleague sender,
        string eventName)
    {
        if (eventName == "OrderSubmitted")
        {
            _payment.ProcessPayment();
            _inventory.ReserveStock();
            _email.SendConfirmation();
        }
        else if (eventName == "LoginAttempted")
        {
            _login.ValidateCredentials();
        }
        else if (eventName == "PasswordReset")
        {
            _email.SendResetLink();
        }
        // This grows forever...
    }
}

The fix is splitting by domain. Each mediator owns one set of related interactions:

using System;

// Good: mediator scoped to order processing
public sealed class OrderMediator : IMediator
{
    private readonly PaymentProcessor _payment;
    private readonly InventoryTracker _inventory;
    private readonly OrderConfirmationNotifier _notifier;

    public OrderMediator(
        PaymentProcessor payment,
        InventoryTracker inventory,
        OrderConfirmationNotifier notifier)
    {
        _payment = payment;
        _inventory = inventory;
        _notifier = notifier;
    }

    public void Notify(
        IColleague sender,
        string eventName)
    {
        switch (eventName)
        {
            case "OrderSubmitted":
                _payment.ProcessPayment();
                _inventory.ReserveStock();
                _notifier.SendConfirmation();
                break;

            case "OrderCancelled":
                _inventory.ReleaseStock();
                _notifier.SendCancellation();
                break;
        }
    }
}

A domain-focused mediator pattern implementation is easier to test because you only need to set up the colleagues for that domain. It's easier to modify because changes to order processing don't risk breaking authentication logic.

Avoid the God Mediator Anti-Pattern

The god mediator is what happens when the "keep it focused" rule gets ignored. It's the most common failure mode of the mediator pattern in C#. A god mediator knows about every component in the system, handles every possible notification, and accumulates conditional branches with every new feature.

Warning signs:

  • The Notify method has more than 10 branches
  • Adding a new feature always requires modifying the mediator
  • The mediator's constructor takes more than 5-6 dependencies
  • The class file exceeds a few hundred lines

The remedy is straightforward: decompose. Extract groups of related handlers into their own mediator classes. This parallels how the facade pattern simplifies complex subsystems -- instead of one facade for everything, you create focused facades per subsystem.

Here's a decomposition example. Suppose a chat application mediator handles room management, message routing, and user presence. Split it:

using System;
using System.Collections.Generic;

public sealed class ChatRoomMediator : IMediator
{
    private readonly List<IChatParticipant> _participants =
        new();

    public void Register(IChatParticipant participant)
    {
        _participants.Add(participant);
        participant.Mediator = this;
    }

    public void Notify(
        IColleague sender,
        string eventName)
    {
        if (eventName == "MessageSent" &&
            sender is IChatParticipant chatSender)
        {
            foreach (var participant in _participants)
            {
                if (participant != sender)
                {
                    participant.ReceiveMessage(
                        chatSender.LastMessage);
                }
            }
        }
    }
}

public interface IChatParticipant : IColleague
{
    string Name { get; }

    string LastMessage { get; }

    void ReceiveMessage(string message);

    void SendMessage(string message);
}

Room management and presence tracking get their own mediators. The ChatRoomMediator above only handles message routing. It doesn't manage who's online or handle room creation. Each concern gets its own mediator pattern coordinator.

Use Strongly-Typed Notifications

String-based event names like "OrderSubmitted" or "MessageSent" are fragile. A typo compiles just fine but silently breaks your coordination logic. A mediator pattern best practice in C# is replacing magic strings with strongly-typed notification objects or enums.

Here's a typed notification approach:

using System;

public interface IMediator<TNotification>
{
    void Notify(
        IColleague sender,
        TNotification notification);
}

public sealed record OrderNotification(
    OrderNotificationType Type,
    string OrderId);

public enum OrderNotificationType
{
    Submitted,
    Cancelled,
    PaymentConfirmed,
    Shipped
}

Now the mediator implementation gets compile-time safety:

using System;

public sealed class OrderMediator
    : IMediator<OrderNotification>
{
    private readonly PaymentProcessor _payment;
    private readonly InventoryTracker _inventory;

    public OrderMediator(
        PaymentProcessor payment,
        InventoryTracker inventory)
    {
        _payment = payment;
        _inventory = inventory;
    }

    public void Notify(
        IColleague sender,
        OrderNotification notification)
    {
        switch (notification.Type)
        {
            case OrderNotificationType.Submitted:
                _payment.ProcessPayment(
                    notification.OrderId);
                _inventory.ReserveStock(
                    notification.OrderId);
                break;

            case OrderNotificationType.Cancelled:
                _inventory.ReleaseStock(
                    notification.OrderId);
                break;
        }
    }
}

The benefits are immediate. Renaming a notification type triggers compile errors everywhere it's used. IDE tooling provides autocomplete for notification types. And you can attach data to the notification record itself rather than relying on the sender to carry state. This approach draws from the same principles that make the command pattern effective -- encapsulating intent as a first-class object.

Handle Errors in Mediated Communication

When a mediator coordinates multiple colleagues, a failure in one shouldn't silently corrupt the others. But it also shouldn't crash the entire coordination chain if one colleague throws an exception. Designing a deliberate error-handling strategy is a mediator pattern best practice in C# that separates production-ready code from prototypes.

There are two primary approaches:

Approach 1: Fail fast. If any step in the coordination fails, stop immediately and propagate the exception. This is appropriate when the steps are transactional -- if payment fails, you shouldn't reserve inventory. In this case, let exceptions propagate naturally through the mediator's Notify method without catching them.

Approach 2: Best effort with logging. Each colleague invocation is wrapped in a try/catch. Failures are logged but don't block the remaining colleagues. This is appropriate for notification-style coordination where each reaction is independent.

using System;
using System.Collections.Generic;

using Microsoft.Extensions.Logging;

public sealed class ResilientChatMediator : IMediator
{
    private readonly List<IChatParticipant> _participants =
        new();
    private readonly ILogger<ResilientChatMediator> _logger;

    public ResilientChatMediator(
        ILogger<ResilientChatMediator> logger)
    {
        _logger = logger;
    }

    public void Register(IChatParticipant participant)
    {
        _participants.Add(participant);
        participant.Mediator = this;
    }

    public void Notify(
        IColleague sender,
        string eventName)
    {
        foreach (var participant in _participants)
        {
            if (participant == sender)
            {
                continue;
            }

            try
            {
                participant.ReceiveMessage(
                    ((IChatParticipant)sender)
                        .LastMessage);
            }
            catch (Exception ex)
            {
                _logger.LogError(
                    ex,
                    "Failed to notify {Participant}",
                    participant.Name);
            }
        }
    }
}

Choose based on whether the mediator pattern coordination is transactional or independent. If a single mediator mixes both styles, consider separating them into distinct mediators.

When to Split Your Mediator

Knowing when a single mediator has outgrown its boundaries is a judgment call, but there are concrete signals. Splitting mediators is a mediator pattern best practice in C# that prevents the gradual slide toward god objects.

Split when:

  • The mediator handles two or more unrelated domains. If order processing and user authentication both route through the same mediator, they should be separated.
  • Adding a new colleague requires modifying the mediator. A well-designed mediator should allow new colleagues to register without changing the mediator's internal logic. If every new feature means adding a new case branch, consider a more extensible design.
  • The mediator has more dependencies than it coordinates. If the constructor takes 8 services but only 3 are used per notification, you have low cohesion.
  • Tests require mocking unrelated components. If testing order submission requires setting up an email notifier and a user profile service, the mediator is doing too much.

Consider an extensible mediator that uses handler registration instead of switch statements:

using System;
using System.Collections.Generic;

public interface INotificationHandler<TNotification>
{
    void Handle(TNotification notification);
}

public sealed class RegistryMediator<TNotification>
{
    private readonly List<INotificationHandler<TNotification>>
        _handlers = new();

    public void Register(
        INotificationHandler<TNotification> handler)
    {
        _handlers.Add(handler);
    }

    public void Publish(TNotification notification)
    {
        foreach (var handler in _handlers)
        {
            handler.Handle(notification);
        }
    }
}

This approach lets you add new handlers without modifying the mediator pattern class at all. Each handler is a small, focused class that implements one reaction to one notification type, and the open/closed principle is preserved. This mirrors the flexibility you get with the strategy pattern -- you swap or extend behavior without modifying existing code.

Testing Strategies for Mediated Components

Testability is where the mediator pattern earns its keep. Because colleagues don't know about each other, you can test them in complete isolation. The mediator pattern itself can be tested with mock colleagues -- a best practice in C# that directly improves your test suite.

Testing Colleagues in Isolation

Each colleague should be testable without a real mediator. Inject a mock mediator and verify that the colleague calls Notify with the correct arguments:

using System;

using Moq;

using Xunit;

public class OrderFormTests
{
    [Fact]
    public void SubmitOrder_NotifiesMediator_WithSubmittedEvent()
    {
        // Arrange
        var mockMediator =
            new Mock<IMediator<OrderNotification>>();
        var orderForm = new OrderForm(mockMediator.Object);

        // Act
        orderForm.Submit("ORD-123");

        // Assert
        mockMediator.Verify(
            m => m.Notify(
                orderForm,
                It.Is<OrderNotification>(n =>
                    n.Type ==
                        OrderNotificationType.Submitted &&
                    n.OrderId == "ORD-123")),
            Times.Once);
    }
}

Testing the Mediator Itself

For the mediator, inject mock colleagues and verify that the correct colleagues are invoked when a notification arrives:

using System;

using Moq;

using Xunit;

public class OrderMediatorTests
{
    [Fact]
    public void Notify_OrderSubmitted_ProcessesPaymentAndReservesStock()
    {
        // Arrange
        var mockPayment = new Mock<PaymentProcessor>();
        var mockInventory = new Mock<InventoryTracker>();
        var mediator = new OrderMediator(
            mockPayment.Object,
            mockInventory.Object);
        var sender = new Mock<IColleague>().Object;

        var notification = new OrderNotification(
            OrderNotificationType.Submitted,
            "ORD-456");

        // Act
        mediator.Notify(sender, notification);

        // Assert
        mockPayment.Verify(
            p => p.ProcessPayment("ORD-456"),
            Times.Once);
        mockInventory.Verify(
            i => i.ReserveStock("ORD-456"),
            Times.Once);
    }
}

Follow the same pattern for additional notification types -- verify the correct colleagues are called and the irrelevant ones are not. This keeps each test focused on one coordination path.

The key testing guidelines for the mediator pattern in C#:

  • Test colleagues independently by mocking the mediator interface.
  • Test the mediator independently by mocking the colleague interfaces.
  • Test that unknown notification types don't cause exceptions.
  • Test error handling paths -- verify logging when colleagues throw.

Performance Considerations

The mediator pattern adds a layer of indirection. For most applications, this overhead is negligible -- a method call through an interface is not a performance bottleneck. But there are scenarios where mediator pattern performance matters in C#, and knowing about them upfront prevents surprises.

Colleague lookup cost. If the mediator iterates through a list of colleagues to find the right one for each notification, the lookup is O(n). For mediators with a handful of colleagues, this is irrelevant. For mediators coordinating hundreds of components -- like a UI framework with many widgets -- consider a dictionary-based lookup keyed on notification type, where handlers are stored in a Dictionary<string, List<Action<object>>> for O(1) event dispatch.

Allocation pressure. Creating notification objects for every mediated call generates garbage collection pressure in hot paths. If you're using the mediator pattern in a game loop or high-frequency system, consider object pooling for notifications or using structs instead of classes. Using records for simple notifications, as shown earlier with OrderNotification, keeps allocations lightweight.

Synchronous vs. asynchronous. Synchronous mediation blocks the caller until all colleagues have been notified. For I/O-bound colleague reactions -- database writes, HTTP calls -- consider an async mediator pattern interface:

using System.Threading;
using System.Threading.Tasks;

public interface IAsyncMediator<TNotification>
{
    Task NotifyAsync(
        IColleague sender,
        TNotification notification,
        CancellationToken cancellationToken = default);
}

This prevents the mediator from becoming a bottleneck when colleagues perform slow operations. It also lets you use CancellationToken for cooperative cancellation, which is important in web applications where requests can be abandoned. The template method pattern can help standardize the async workflow within your colleague implementations.

Organizing Mediator Pattern Code in Your Project

Group related mediator pattern components together by domain or feature, not by pattern type. A practical folder structure:

src/
  OrderProcessing/
    IOrderMediator.cs
    OrderMediator.cs
    OrderForm.cs
    PaymentProcessor.cs
    Notifications/
      OrderNotification.cs
  Chat/
    IChatMediator.cs
    ChatRoomMediator.cs
    ChatParticipant.cs

Avoid putting all mediators in a generic Mediators folder. That structure groups by pattern rather than by purpose. Feature-based organization scales better as the codebase grows. The decorator pattern can layer additional behavior -- like logging or metrics -- onto mediators without modifying the core coordination logic.

Frequently Asked Questions

What is the god mediator anti-pattern and how do I avoid it?

The god mediator is a mediator class that knows about every component in your system and handles every notification type. Avoid it by scoping each mediator to a single domain, keeping the number of colleagues per mediator small, and splitting when it accumulates more than a handful of notification types.

How do I test mediator pattern code in C# effectively?

Test colleagues and mediators independently. Mock the mediator interface when testing a colleague to verify it sends the right notifications. Mock colleague interfaces when testing the mediator to verify it coordinates the right reactions. Always test that unrecognized notifications don't throw exceptions, and verify error-handling behavior by having mock colleagues throw controlled exceptions.

Should I use string-based or strongly-typed notifications with the mediator pattern?

Strongly-typed notifications are the recommended mediator pattern approach. String-based event names like "OrderSubmitted" are error-prone because typos compile silently. Use enums, record types, or dedicated notification classes. You get compile-time safety, IDE autocomplete, and the ability to attach structured data to the notification itself.

When should I split a mediator into multiple mediators?

Split when the mediator handles unrelated domains, when its constructor takes too many dependencies, when adding a new colleague forces you to modify the mediator, or when tests require mocking components irrelevant to the scenario being tested. A good rule of thumb: if you can describe the mediator's purpose in one sentence scoped to a single domain, it's the right size.

How does the mediator pattern compare to an event bus or the observer pattern?

The mediator pattern is an in-process coordination mechanism with centralized logic. An event bus or message broker -- like RabbitMQ or Azure Service Bus -- operates across process boundaries with serialization and eventual consistency. The observer pattern broadcasts one-to-many without centralized control. The mediator centralizes communication so all coordination logic lives in one place. Use the mediator when you need a central coordinator managing complex interactions. Use the observer when subscribers are independent and don't need coordinated reactions.

What are the performance implications of using the mediator pattern in C#?

For most applications, the performance overhead is negligible -- it amounts to an extra interface method call and possibly a loop over registered colleagues. In high-frequency scenarios, consider dictionary-based handler lookups instead of list iteration, struct-based notification objects to reduce GC pressure, and async mediator interfaces to avoid blocking on I/O-bound colleague reactions.

Can I use dependency injection to wire up mediators and colleagues in C#?

Yes, and you should. Register mediator interfaces and colleague interfaces with your DI container. Use scoped or transient lifetimes depending on whether the mediator needs to maintain state across a request. The DI container resolves the dependency graph automatically, which keeps your composition root clean and makes it easy to swap implementations for testing.

Wrapping Up Mediator Pattern Best Practices

Applying these mediator pattern best practices in C# will help you build coordination logic that stays maintainable as your codebase grows. The core themes are consistent: program to interfaces so colleagues and mediators are independently testable, keep each mediator focused on a single domain, use strongly-typed notifications instead of magic strings, handle errors deliberately based on whether coordination is transactional or independent, and split mediators before they become god objects.

The mediator pattern is at its best when you treat it as a focused coordinator for a specific set of interactions. When you enforce interface-based contracts, organize code by feature, and design error handling around the nature of the coordination, you get a system that's easy to extend, test, and reason about.

Start with a simple mediator for a single domain. Add strongly-typed notifications. Register everything through dependency injection. And resist the urge to route unrelated communication through the same mediator just because it's convenient. That discipline is what separates mediator pattern code that scales from code that collapses.

Strategy Pattern Best Practices in C#: Code Organization and Maintainability

Strategy pattern best practices in C#: code organization, maintainability tips, dependency injection, testing strategies, and professional implementation guidelines.

How to Implement Mediator Pattern in C#: Step-by-Step Guide

How to implement the mediator pattern in C# with step-by-step code examples, best practices, and common pitfalls for behavioral design patterns.

Mediator Design Pattern in C#: Complete Guide with Examples

Master the mediator design pattern in C# with code examples, real-world scenarios, and best practices for reducing object coupling.

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