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
- What problem does the Mediator pattern solve in C#?
- How does MediatR implement Mediator notifications?
- How does Mediator differ from Observer?
- When does the Mediator pattern misfire?
- How does MediatR handle exceptions in notifications?
- What does a real .NET 10 example look like?
- 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:
- Domain events. "Order paid" must trigger inventory, shipping, email, analytics. The publisher should not know the subscriber list.
- Cross-feature reactions. A new feature wants to react when an existing event happens, without modifying the existing feature's code.
- 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:
- Indirection without payoff. Three classes, one notification, no plans to grow. Direct method calls are simpler. Mediator earns its keep at scale.
- Cyclic publishes. Handler A publishes a notification that Handler B handles, which publishes another that A handles. The hub does not detect the cycle; debug logs lie because each publish looks innocent. Document the publication graph; treat cycles as bugs.
- Hidden ordering dependencies. A subscriber assumes another subscriber has already run. The hub does not guarantee any order. Make handlers independent; if one must run first, do not use a notification — use a Command with explicit sequencing.
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.
Where should you read next in this series?
- Previous: Iterator — uniform traversal of collections.
- Next: Memento — snapshotting state for undo or transactional rollback.
- Cross-reference: Observer — when the publisher owns its subscriber list directly.
- Cross-reference: Command — paired with Mediator in MediatR; one for "send and get a result", the other for "publish to whoever cares".
- Decision tree: How to choose the right design pattern.
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?
Is MediatR really the Mediator pattern?
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?
How do you handle errors when a notification has multiple handlers?
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.