Mert Özen Further With Every Line
Software Design Design Patterns — #17

Mediator Pattern: Centralizing Object Conversations and "Do I Really Need MediatR?"

Mert Özen Nov 7, 2026 13 min 2 views
Mediator Pattern: Centralizing Object Conversations and "Do I Really Need MediatR?"

Mediator gathers the interaction rules of components that call each other directly into one object; MediatR is less this pattern than an in-process dispatcher that finds a handler by request type. We fix the bug produced by checkout components that know each other with a CheckoutForm mediator, study Blazor's EditContext as a real example, then write the same order placement with MediatR 14.3, a plain service class and our own 30-line dispatcher and compare them.

Version note: The code in this post was built and run with C# 14 and .NET 10.

Guess First

Bookstore's checkout screen has three components: a coupon box, a shipping selector and an order total label. The rules are simple: the FREESHIP coupon switches shipping to Standard and makes it free; if the user switches to Express, the coupon becomes invalid. The developer who built the screen gave each component a reference to the other components it needs:

public sealed class CouponBox
{
    public ShippingSelector Shipping { get; set; } = null!;
    public TotalLabel Total { get; set; } = null!;
    public string? Code { get; private set; }

    public void Apply(string code)
    {
        Code = code;
        if (code == "FREESHIP")
            Shipping.Select("Standard");
        Total.Refresh();
    }

    public void Revalidate()
    {
        if (Code == "FREESHIP" && Shipping.Method != "Standard")
        {
            Code = null;
            Console.WriteLine("Coupon removed");
            Total.Refresh();
        }
    }
}

public sealed class ShippingSelector
{
    public CouponBox Coupon { get; set; } = null!;
    public TotalLabel Total { get; set; } = null!;
    public string Method { get; private set; } = "Express";

    public void Select(string method)
    {
        Method = method;
        Total.Refresh();
        Coupon.Revalidate();
    }
}

public sealed class TotalLabel(decimal subtotal, CouponBox coupon, ShippingSelector shipping)
{
    public void Refresh()
    {
        decimal fee = coupon.Code == "FREESHIP" ? 0m : shipping.Method == "Express" ? 25m : 10m;
        Console.WriteLine($"Total: {subtotal + fee}");
    }
}

DirectCheckout checkout = DirectCheckout.Create(subtotal: 80m);
Console.WriteLine("-- apply FREESHIP");
checkout.Coupon.Apply("FREESHIP");
Console.WriteLine("-- select Express");
checkout.Shipping.Select("Express");

The cart is 80, Standard shipping is 10, Express is 25. When the coupon is applied, how many times is "Total" printed? When the user then picks Express, which amounts does the label show, and in what order? If a payment component were listening to the total, which amount would it ask the card to authorize?

Components That Know Each Other

The real output:

-- apply FREESHIP
Total: 80
Total: 80
-- select Express
Total: 80
Coupon removed
Total: 105

Applying the coupon calculated the total twice: once inside ShippingSelector.Select and once at the end of CouponBox.Apply. Selecting Express was worse: Select refreshed the total first and, since the coupon had not been dropped yet, the label briefly showed 80; only the Revalidate call that followed removed the coupon and printed the correct amount, 105. No single class is wrong. The bug is that the rule "re-check the coupon when shipping changes and calculate the total last" is spread across three classes. The order of the rule depends on who calls whom, in what order.

There are six references between three components; add a fourth (say, gift wrapping) and each might have to know the other three. On a screen with N components the number of links grows towards N(N−1). This post's pattern exists to cut that growth.

GoF's Definition: Gathering the Interaction into One Object

GoF (Gang of Four: the 1994 Design Patterns book by Gamma, Helm, Johnson and Vlissides) lists Mediator among the behavioral patterns. Its intent is to put how a group of objects interact inside a single object; because the objects don't refer to each other directly, you get loose coupling, and the interaction rules can change independently of them. There are three roles: Mediator (the interface colleagues notify), ConcreteMediator (the class that knows the coordination logic and knows the colleagues) and Colleague (components that only know their mediator). The book's motivating example is very close to ours: a font selection dialog box. When the selection in a list box changes, an entry field is updated; when the field is empty, a button is disabled. Those rules are known by a DialogDirector, not by the widgets.

The book does not hide the pattern's cost either: you move complexity from the colleagues into the mediator, and if you are not careful the mediator turns into a monolithic class nobody understands. GoF also separates Mediator from two neighbours. A Facade opens a one-way, simple door to a subsystem; the subsystem doesn't know the facade. In a Mediator communication goes both ways; colleagues notify the mediator. Facade itself is in this series' post "Facade Pattern: Presenting a Complex Subsystem Through a Single Door". Observer can also be used for colleagues to notify the mediator; we will get to it two posts from now.

Mediator in Bookstore: CheckoutForm

// Mediator
public interface ICheckoutMediator
{
    void Changed(CheckoutComponent sender);
}

// Colleague: knows only the mediator
public abstract class CheckoutComponent(ICheckoutMediator mediator)
{
    protected void Changed() => mediator.Changed(this);
}

public sealed class CouponBox(ICheckoutMediator mediator) : CheckoutComponent(mediator)
{
    public string? Code { get; private set; }

    public void Apply(string code)
    {
        Code = code;
        Changed();
    }

    internal void Clear() => Code = null;
}

public sealed class ShippingSelector(ICheckoutMediator mediator) : CheckoutComponent(mediator)
{
    public string Method { get; private set; } = "Express";

    public void Select(string method)
    {
        Method = method;
        Changed();
    }

    internal void Set(string method) => Method = method;
}

public sealed class TotalLabel
{
    public void Show(decimal total) => Console.WriteLine($"Total: {total}");
}

// ConcreteMediator: the interaction rules in one place
public sealed class CheckoutForm : ICheckoutMediator
{
    private readonly decimal _subtotal;

    public CheckoutForm(decimal subtotal)
    {
        _subtotal = subtotal;
        Coupon = new CouponBox(this);
        Shipping = new ShippingSelector(this);
    }

    public CouponBox Coupon { get; }
    public ShippingSelector Shipping { get; }
    public TotalLabel Total { get; } = new();

    public void Changed(CheckoutComponent sender)
    {
        if (sender == Coupon && Coupon.Code == "FREESHIP")
            Shipping.Set("Standard");

        if (sender == Shipping && Coupon.Code == "FREESHIP" && Shipping.Method != "Standard")
        {
            Coupon.Clear();
            Console.WriteLine("Coupon removed");
        }

        decimal fee = Coupon.Code == "FREESHIP" ? 0m : Shipping.Method == "Express" ? 25m : 10m;
        Total.Show(_subtotal + fee);
    }
}

The same scenario:

-- apply FREESHIP
Total: 80
-- select Express
Coupon removed
Total: 105

The total is now calculated once, and correctly, for each user action. The components no longer know each other's names; all CouponBox knows is "something changed, tell the mediator". The order of the rule (fix shipping first, then check the coupon, show the total last) reads in a single method. We used "silent" methods such as Set and Clear so that the mediator does not trigger Changed again while it updates colleagues; otherwise the mediator would call itself and rebuild the same loop in the centre.

A Real Example in .NET: Blazor's EditContext

ASP.NET Core's Blazor form infrastructure is a textbook example of this pattern. When its value changes, an input component such as InputText doesn't call the validator or the summary component; it says EditContext.NotifyFieldChanged. DataAnnotationsValidator subscribes to the OnFieldChanged event of the EditContext, and ValidationSummary subscribes to OnValidationStateChanged. The three don't know each other; they all know the same EditContext. We can reproduce this in a console app with Blazor's own types:

CheckoutModel model = new();
EditContext context = new(model);

// Validator: knows only the EditContext
ValidationMessageStore messages = new(context);
context.OnFieldChanged += (_, e) =>
{
    messages.Clear(e.FieldIdentifier);
    if (e.FieldIdentifier.FieldName == nameof(CheckoutModel.Email) && !model.Email.Contains('@'))
        messages.Add(e.FieldIdentifier, "Email is invalid");
    context.NotifyValidationStateChanged();
};

// Summary: it also knows only the EditContext
context.OnValidationStateChanged += (_, _) =>
    Console.WriteLine($"Summary: [{string.Join("; ", context.GetValidationMessages())}]");

// Input: writes the value and notifies the EditContext
model.Email = "ada";
context.NotifyFieldChanged(context.Field(nameof(CheckoutModel.Email)));
model.Email = "ada@example.com";
context.NotifyFieldChanged(context.Field(nameof(CheckoutModel.Email)));
Console.WriteLine($"Modified: {context.IsModified()}");
Summary: [Email is invalid]
Summary: []
Modified: True

All we added to the project was <FrameworkReference Include="Microsoft.AspNetCore.App" />; the types live in ASP.NET Core's shared framework. Here EditContext is the ConcreteMediator, and the input, validator and summary are the colleagues. Adding a new validator to your form doesn't require touching the input component, because the input never knows who is listening. Notice that the mediator uses events, that is Observer, to notify the colleagues; this is exactly what GoF says about the relationship between the two patterns.

What Is MediatR, Really?

In the .NET world, "mediator" makes most people think of the MediatR library. Let's write the same order placement with MediatR:

public sealed record PlaceOrder(int CustomerId, IReadOnlyList<string> Isbns) : IRequest<OrderPlaced>;

public sealed class PlaceOrderHandler(Inventory inventory) : IRequestHandler<PlaceOrder, OrderPlaced>
{
    public Task<OrderPlaced> Handle(PlaceOrder request, CancellationToken cancellationToken) =>
        Task.FromResult(inventory.Reserve(request.Isbns));
}

public sealed class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    where TRequest : notnull
{
    public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
    {
        Console.WriteLine($"-> {typeof(TRequest).Name}");
        TResponse response = await next(cancellationToken);
        Console.WriteLine($"<- {typeof(TRequest).Name}");
        return response;
    }
}

ServiceCollection services = new();
services.AddLogging(b => b.AddSimpleConsole(o => o.SingleLine = true));
services.AddSingleton<Bookstore.Orders.Inventory>();
services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssemblyContaining<Bookstore.Orders.WithMediatR.PlaceOrderHandler>();
    cfg.AddOpenBehavior(typeof(Bookstore.Orders.WithMediatR.LoggingBehavior<,>));
});
using ServiceProvider provider = services.BuildServiceProvider();

MediatR.IMediator mediator = provider.GetRequiredService<MediatR.IMediator>();
Bookstore.Orders.OrderPlaced result = await mediator.Send(
    new Bookstore.Orders.WithMediatR.PlaceOrder(CustomerId: 7, Isbns: ["978-0441013593", "978-0201633610"]));
Console.WriteLine(result);
-> PlaceOrder
warn: LuckyPennySoftware.MediatR.License[0] You do not have a valid license key for the Lucky Penny software MediatR. This is allowed for development and testing scenarios. If you are running in production you are required to have a licensed version. Please visit https://luckypennysoftware.com to obtain a valid license.
<- PlaceOrder
OrderPlaced { OrderId = 1001, Total = 73 }

MediatR describes itself as "in-process messaging": it looks at the type of the request you pass to Send, finds the single handler registered for that type in the DI (dependency injection) container, wraps the registered pipeline behaviors around it and calls it. Publish distributes a notification to every handler registered for its type. What this shares with GoF's Mediator is that the sender doesn't know the receiver. The difference is bigger: GoF's ConcreteMediator knows its colleagues and contains the rule for how they interact; CheckoutForm knows the relationship between coupon and shipping. MediatR contains no business rule at all, only a mapping of "take this request type to that handler". Requests don't talk to each other; most of the time a request has exactly one handler. In GoF terms this is a combination of Command, which turns the request into an object, and a dispatcher that resolves the handler by type; Command is in this series' post "Command Pattern: Turning Requests into Objects, Undo/Redo and Queuing". The pipeline behaviors are a Chain of Responsibility wrapped around the handler, the same idea as ASP.NET Core middleware, covered in this series' post "Chain of Responsibility: ASP.NET Core Middleware, DelegatingHandler and Validation Chains".

The warn line in the output is real too. The version we used is MediatR 14.3.0; 13.0 (July 2025) and later are distributed by Lucky Penny Software under a dual license, its commercial license or RPL (Reciprocal Public License) 1.5. A missing license key doesn't block anything; it only logs this warning. There is a free Community license for companies with annual revenue under 5 million dollars (with extra conditions such as outside funding and government agencies), but it also needs a key. These facts are time-sensitive (this post was checked against mediatr.io and the NuGet page in November 2026). The comparison of libraries and what the license means for a project decision are in the third post of the upcoming Vertical Slice Architecture series; here we only look at one question: can you do without a library?

Without a Library (1): A Plain Service Class

Most of the separation MediatR gives you comes from making the handler a separate class, not from the dispatcher. You can write the same handler without an interface or library and inject it directly:

public sealed record PlaceOrder(int CustomerId, IReadOnlyList<string> Isbns);

// No interface, no library: the endpoint injects this class directly
public sealed class PlaceOrderHandler(Inventory inventory)
{
    public Task<OrderPlaced> Handle(PlaceOrder request, CancellationToken ct = default) =>
        Task.FromResult(inventory.Reserve(request.Isbns));
}

public static class OrderEndpoints
{
    public static void Configure(WebApplicationBuilder builder) =>
        builder.Services.AddScoped<PlaceOrderHandler>();

    public static void Map(WebApplication app) =>
        app.MapPost("/orders", async (PlaceOrder command, PlaceOrderHandler handler, CancellationToken ct) =>
            TypedResults.Ok(await handler.Handle(command, ct)));
}
OrderPlaced { OrderId = 1001, Total = 73 }

The endpoint knows the handler, yes; but it already knew IMediator and the request type. What you gain is concrete: go-to-definition on handler.Handle lands directly in the business code, the compiler catches a missing handler at build time instead of at runtime, and there are no generic wrappers in the call stack. For cross-cutting work such as logging, validation or transactions you use ASP.NET Core's endpoint filters instead of pipeline behaviors, or the decorators from this series' post "Decorator Pattern: Adding Caching, Logging and Retry Without Touching the Code". Filters themselves are covered in the ASP.NET Core Web API (Application Programming Interface) series post "Filters in ASP.NET Core Web API: Action Filters, Result Filters and ServiceFilter".

Without a Library (2): Your Own 30-Line Dispatcher

If the team still wants "every request goes through one door and behaviors apply to every request automatically", the code needed is smaller than people think:

public interface IRequest<TResponse>;

public interface IRequestHandler<TRequest, TResponse> where TRequest : IRequest<TResponse>
{
    Task<TResponse> Handle(TRequest request, CancellationToken ct);
}

public interface IPipelineBehavior<TRequest, TResponse> where TRequest : IRequest<TResponse>
{
    Task<TResponse> Handle(TRequest request, Func<Task<TResponse>> next, CancellationToken ct);
}

public sealed class Dispatcher(IServiceProvider services)
{
    private static readonly ConcurrentDictionary<Type, object> Pipelines = new();

    public Task<TResponse> Send<TResponse>(IRequest<TResponse> request, CancellationToken ct = default)
    {
        // Build the closed generic Pipeline for the request's real type once and cache it
        Pipeline<TResponse> pipeline = (Pipeline<TResponse>)Pipelines.GetOrAdd(request.GetType(),
            t => Activator.CreateInstance(typeof(Pipeline<,>).MakeGenericType(t, typeof(TResponse)))!);
        return pipeline.Run(request, services, ct);
    }

    private abstract class Pipeline<TResponse>
    {
        public abstract Task<TResponse> Run(object request, IServiceProvider sp, CancellationToken ct);
    }

    private sealed class Pipeline<TRequest, TResponse> : Pipeline<TResponse> where TRequest : IRequest<TResponse>
    {
        public override Task<TResponse> Run(object request, IServiceProvider sp, CancellationToken ct)
        {
            TRequest typed = (TRequest)request;
            IRequestHandler<TRequest, TResponse> handler = sp.GetRequiredService<IRequestHandler<TRequest, TResponse>>();
            Func<Task<TResponse>> next = () => handler.Handle(typed, ct);
            foreach (IPipelineBehavior<TRequest, TResponse> behavior in sp.GetServices<IPipelineBehavior<TRequest, TResponse>>().Reverse())
            {
                Func<Task<TResponse>> inner = next;
                next = () => behavior.Handle(typed, inner, ct);
            }
            return next();
        }
    }
}

The Dispatcher class is 29 lines without comments and blank lines. The only hard part is that at the Send(request) call the compiler only knows TResponse: the request's real type is only known at runtime. So we use MakeGenericType to build a closed generic Pipeline<PlaceOrder, OrderPlaced> once per request type and cache it; later calls involve no reflection. Behaviors wrap from the outside in, in registration order. Registration and usage:

ServiceCollection services = new();
services.AddSingleton<Bookstore.Orders.Inventory>();
services.AddSingleton<Bookstore.Messaging.Dispatcher>();
services.AddScoped<Bookstore.Messaging.IRequestHandler<Bookstore.Orders.OwnDispatcher.PlaceOrder, Bookstore.Orders.OrderPlaced>,
    Bookstore.Orders.OwnDispatcher.PlaceOrderHandler>();
services.AddScoped(typeof(Bookstore.Messaging.IPipelineBehavior<,>), typeof(Bookstore.Orders.OwnDispatcher.LoggingBehavior<,>));
using ServiceProvider provider = services.BuildServiceProvider();

Bookstore.Messaging.Dispatcher dispatcher = provider.GetRequiredService<Bookstore.Messaging.Dispatcher>();
Bookstore.Orders.OrderPlaced result = await dispatcher.Send(
    new Bookstore.Orders.OwnDispatcher.PlaceOrder(CustomerId: 7, Isbns: ["978-0441013593", "978-0201633610"]));
Console.WriteLine(result);
-> PlaceOrder
<- PlaceOrder
OrderPlaced { OrderId = 1001, Total = 73 }

The output is the same as MediatR's, minus the warning line. Let's be honest about what is missing: notifications and Publish, stream requests, automatic registration by assembly scanning, exception handlers, and generic variance letting one handler match several request types. There is one more detail to watch in the example: we registered Dispatcher as a singleton and it resolves handlers from the root IServiceProvider; to get scoped handlers from the right scope in ASP.NET Core you need to register the dispatcher as scoped. That trap is explained in the C# Advanced series post "Dependency Injection Basics in C#: Lifetimes, the Container and Captive Dependencies".

Common Pitfalls

Losing navigation. On the line await mediator.Send(new PlaceOrder(...)), go-to-definition takes you to the declaration of ISender.Send, not to the handler. To find the handler you walk the "find all references" list of the request type. This cost looks trivial in a small codebase, but in a project with hundreds of requests it seriously slows down how fast a newcomer can read the code.

Sending from handler to handler. Sending another request from inside a handler hides the call graph completely: if PlaceOrderHandler calls stock, payment and email requests through the mediator, you can't tell by reading the code which work runs in which transaction. If there is shared work, move it into a plain service class and inject it into both handlers.

The "everything goes through the mediator" rule. Writing a request, a handler and a registration for a one-line read query adds only indirection, with no problem for a Mediator to solve. The team may have adopted the rule without discussing it; the first step is to ask "what does this indirection buy us?"

The GoF Mediator turning into a god object. CheckoutForm knows two rules today. Once gift wrapping, loyalty points and instalment options arrive, its Changed method can become a pile of ifs nobody dares to touch. That is precisely the monolith GoF warns about. When you see the symptoms, split the rules into small named methods, and into more than one mediator if needed.

Order and exceptions in notifications. MediatR's default notification publisher awaits handlers one after another; if one throws, the ones after it don't run. If you attach "nice to have" work (email, cache invalidation) to a notification, choose this behavior deliberately. Observer has the same problem, and we will measure it in detail there.

When Not to Use It

For GoF Mediator: don't build a mediator for a single relationship between two components; a direct call or an event is enough. The pattern pays off when the interaction rules of three or more connected components become order-dependent, that is, when the question "who calls whom, in what order" produces bugs, as in the opening example.

For MediatR or your own dispatcher: if most requests are called from a single endpoint and the framework's filters already handle cross-cutting work, a dispatcher gains you nothing; start with a plain service class. What really makes a dispatcher worth it is calling the same request from several entry points, such as an HTTP (Hypertext Transfer Protocol) endpoint, a background job and a message consumer, and applying the same behavior chain to all of them. Once you get there, the question of a 30-line dispatcher, MediatR or another library becomes an architecture decision; the whole of that decision is in the upcoming Vertical Slice Architecture series.

A Small Experiment

★☆☆ Add a GiftWrap colleague to CheckoutForm: if selected it adds 5 to the total, and it can't be combined with FREESHIP. How many lines changed in CouponBox and ShippingSelector?

★★☆ Add a ValidationBehavior to your own dispatcher: if the request has an empty Isbns list, it throws without ever reaching the handler. Register the behaviors in a different order and see how the order of the output changes.

★★★ Add Publish<TNotification> to the dispatcher and write two strategies: one that calls handlers in sequence and stops at the first exception, and one that runs them all and collects the exceptions in an AggregateException. Try both with one handler throwing, and decide which you would pick for which kind of work.

Mediator put the conversation between objects at the centre. The next post deals with an object's own history: taking a snapshot of a multi-step checkout form's draft, saying "undo", and doing it without breaking the object's encapsulation, in other words Memento.