Behavioral Intermediate 6 min read

Mediator Pattern in C#: MediatR Notifications and Hubs

Mediator pattern in C# / .NET 10: peers publish to a hub instead of calling each other, with MediatR notifications as the standard implementation.

Table of contents
  1. What problem does the Mediator pattern solve in C#?
  2. How does MediatR implement Mediator notifications?
  3. How does Mediator differ from Observer?
  4. When does the Mediator pattern misfire?
  5. How does MediatR handle exceptions in notifications?
  6. What does a real .NET 10 example look like?
  7. Where should you read next in this series?

When an order is paid, four things must happen: the inventory must reserve stock, the warehouse must be told to ship, the customer must receive a confirmation email, and the analytics module must record revenue. The first version wires those four into the checkout service: it depends on IInventoryService, IShippingService, INotificationService, and IAnalytics. Six months later, finance asks for a fifth call (post a journal entry), then fraud asks for a sixth (re-score the customer). Now the checkout service has eight injections and the team that owns it fights merge conflicts on every release.

The Mediator pattern is the answer. Have the checkout publish a single notification — OrderPaid — to a hub. The hub routes it to whichever handlers care. Each new use case is one new handler in its own module; the checkout service does not change. In modern .NET this is what IMediator.Publish from MediatR does, and the same pattern can be hand-rolled when you do not want a dependency.

What problem does the Mediator pattern solve in C#?

The pattern earns its place when several peer objects must co-ordinate, but should not all hold direct references to each other. Three concrete shapes:

  1. Domain events. "Order paid" must trigger inventory, shipping, email, analytics. The publisher should not know the subscriber list.
  2. Cross-feature reactions. A new feature wants to react when an existing event happens, without modifying the existing feature's code.
  3. In-process bus. A monolith with several modules wants the modular benefits of message-bus loose coupling without operating a real broker.

What is not a Mediator problem: "I want to add behaviour around one call" — that is Decorator. "I want to dispatch one Command to one handler" — that is Command (and MediatR does both at once).

How does MediatR implement Mediator notifications?

A notification record, one or more handlers, and a single _mediator.Publish at the source:

using MediatR;

public sealed record OrderPaid(string OrderId, string CustomerId, decimal Total)
    : INotification;

public sealed class ReserveInventory : INotificationHandler<OrderPaid>
{
    private readonly IInventoryService _inv;
    public ReserveInventory(IInventoryService inv) => _inv = inv;
    public Task Handle(OrderPaid n, CancellationToken ct)
        => _inv.ReserveForAsync(n.OrderId, ct);
}

public sealed class SendConfirmationEmail : INotificationHandler<OrderPaid>
{
    private readonly INotificationService _emails;
    public SendConfirmationEmail(INotificationService emails) => _emails = emails;
    public Task Handle(OrderPaid n, CancellationToken ct)
        => _emails.SendOrderPaidAsync(n.CustomerId, n.OrderId, ct);
}

// Composition root
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining<Program>());

// Publisher
public sealed class CheckoutService
{
    private readonly IMediator _mediator;
    public CheckoutService(IMediator m) => _mediator = m;

    public async Task PaidAsync(Order order, CancellationToken ct)
    {
        // ... persist order, mark paid ...
        await _mediator.Publish(new OrderPaid(order.Id, order.CustomerId, order.Total), ct);
    }
}

Adding a sixth reaction is one new handler class plus the DI registration MediatR's assembly scan picks up automatically. CheckoutService continues to depend on IMediator only.

The structural picture:

flowchart LR
    Pub[CheckoutService<br>publishes OrderPaid]
    Pub --> Hub[IMediator hub]
    Hub --> H1[ReserveInventory]
    Hub --> H2[SendConfirmationEmail]
    Hub --> H3[BookShipping]
    Hub --> H4[RecordAnalytics]

The hub knows about the handlers; the publisher knows only the hub. New handlers slot in at the hub; the publisher is untouched.

How does Mediator differ from Observer?

Aspect Mediator Observer
Hub class Required (the central component) Not required
Direction Many publishers to many subscribers via hub One publisher to many subscribers directly
Subscriber discovery Hub uses DI / type matching Subscriber attaches via event += or Subscribe
Typical .NET MediatR IMediator.Publish C# event, IObservable<T>, Channel<T>

The cleanest split: Observer is the publisher's own list of subscribers; Mediator pulls that list out into a hub that everyone uses. When the publisher and subscriber are tightly coupled, prefer Observer (events). When the publisher should not know its subscribers exist, prefer Mediator.

When does the Mediator pattern misfire?

Three traps:

How does MediatR handle exceptions in notifications?

By default, MediatR uses ForeachAwaitPublisher: it awaits handlers in registration order; if one throws, the rest do not run. To change the policy:

builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssemblyContaining<Program>();
    cfg.NotificationPublisherType = typeof(TaskWhenAllPublisher);
});

TaskWhenAllPublisher runs handlers in parallel and aggregates exceptions. For fire-and-forget, write your own INotificationPublisher. Always pick the policy explicitly — the default is rarely what production wants.

What does a real .NET 10 example look like?

Combining MediatR notifications, the Decorator-style logging pipeline from Command, and Chain of Responsibility behaviours:

public sealed record OrderPaid(string OrderId, string CustomerId, decimal Total)
    : INotification;

public sealed class ReserveInventory : INotificationHandler<OrderPaid>
{
    private readonly IInventoryService _inv;
    public ReserveInventory(IInventoryService inv) => _inv = inv;
    public Task Handle(OrderPaid n, CancellationToken ct)
        => _inv.ReserveForAsync(n.OrderId, ct);
}

public sealed class SendConfirmationEmail : INotificationHandler<OrderPaid>
{
    private readonly INotificationService _emails;
    public SendConfirmationEmail(INotificationService emails) => _emails = emails;
    public Task Handle(OrderPaid n, CancellationToken ct)
        => _emails.SendOrderPaidAsync(n.CustomerId, n.OrderId, ct);
}

public sealed class RecordAnalytics : INotificationHandler<OrderPaid>
{
    private readonly IAnalytics _analytics;
    public RecordAnalytics(IAnalytics a) => _analytics = a;
    public Task Handle(OrderPaid n, CancellationToken ct)
        => _analytics.RecordRevenueAsync(n.Total, ct);
}

// Composition root
builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssemblyContaining<Program>();
    cfg.NotificationPublisherType = typeof(TaskWhenAllPublisher);
});

// Publisher
public sealed class CheckoutService
{
    private readonly IMediator _mediator;
    public CheckoutService(IMediator m) => _mediator = m;

    public async Task<CheckoutResult> CheckoutAsync(CheckoutRequest req, CancellationToken ct)
    {
        // ... domain work, then ...
        await _mediator.Publish(new OrderPaid(req.CartId, req.CustomerId, req.Total), ct);
        return new CheckoutResult(req.CartId, req.Total, "buyer@x.com");
    }
}

// Test
[Fact]
public async Task ReserveInventory_handles_OrderPaid()
{
    var inv = new Mock<IInventoryService>();
    var sut = new ReserveInventory(inv.Object);
    await sut.Handle(new OrderPaid("o1", "c1", 9.99m), default);
    inv.Verify(i => i.ReserveForAsync("o1", It.IsAny<CancellationToken>()));
}

The publisher does not import inventory, email, or analytics. Each handler is a small class with one dependency. Adding a fifth reaction (finance journal entry) is one new file — and zero edits to the checkout service.

A practical note: Mediator buys you decoupling at the cost of discoverability. A junior developer asked "what happens when we publish OrderPaid?" cannot answer by reading the publisher; they need to know the assemblies the handlers live in. Compensate with naming conventions (*Handler.cs), an architecture-decision record, and good test coverage on each handler.

Frequently asked questions

What is the difference between Mediator and Observer?
Observer is one publisher to many subscribers — the publisher knows it has subscribers (or at least owns the event). Mediator inverts that: many peers publish to a hub, and the hub routes to handlers. Observer is direct, point-to-many; Mediator is indirect, many-to-many through a central component.
Is MediatR really the Mediator pattern?
Yes — MediatR combines Mediator (the IMediator hub plus INotificationHandler<T> subscribers) with Command (IRequest<T> plus IRequestHandler<T,U>). The library's name is the giveaway. Most .NET teams using MediatR are using both patterns; recognising which is which is what makes the library more legible.
When does Mediator add too much indirection?
When you have two peers and one notification. A direct method call is shorter, easier to debug, and easier to test. Mediator earns its keep when there are several peers, several notifications, and the publish-side does not know all the subscribers. If you can name every handler in advance, plain method calls are simpler.
How do you handle errors when a notification has multiple handlers?
Decide on the policy explicitly. MediatR's default is all-or-nothing — one handler throws, the publish call throws and remaining handlers may not run. Implement INotificationPublisher to switch to parallel-no-wait or fire-and-forget if your handlers are independent. Document the policy near the notification type so future readers know which behaviour they are signing up for.