Mediator pattern: a Design Patterns guide for developers
When you have ten components all talking directly to each other, your codebase becomes a tangled web of dependencies that's impossible to refactor. The Mediator pattern solves this by introducing a central coordinator that components communicate through instead of calling each other directly. We'll explore how this pattern simplifies complex interactions, when to apply it, and the pitfalls that come with overdoing it.
The problem — Ten components all calling each other directly
Imagine building an order management system with a Cart, Inventory, Payment, Shipping, Notification, and Audit component. Each one needs to coordinate with multiple others:
CartcallsInventoryto check stock, then callsPaymentto charge the card, then callsShippingto calculate costs.PaymentcallsAuditto log transactions and callsNotificationto email receipts.InventorycallsAuditwhenever stock changes and callsNotificationfor low-stock alerts.ShippingcallsNotificationto send tracking info.
This creates a coupling nightmare. Each component has hard references to multiple others, testing becomes difficult (you need to mock half the system for each test), and changing one component's signature cascades changes everywhere. The control flow is scattered across the codebase, making it hard to understand the sequence of operations at a glance.
// Without a mediator — tightly coupled mess
class Cart {
private inventory: Inventory;
private payment: Payment;
private shipping: Shipping;
private notification: Notification;
async checkout(items: OrderItem[], card: CreditCard) {
// Cart decides the flow and calls everyone directly
const available = await this.inventory.checkStock(items);
if (!available) throw new Error('Out of stock');
const charged = await this.payment.charge(card, this.calculateTotal(items));
if (!charged) throw new Error('Payment failed');
const shippingCost = await this.shipping.calculateCost(items);
// Notification needs to be called from Cart, Payment, and Shipping
await this.notification.sendReceipt(charged);
// New requirement: audit log
// Now Cart needs to know about Audit too
}
}
As requirements grow, you're forced to give Cart dependencies on more and more services. The class becomes a god object that orchestrates everything. Testing Cart.checkout means setting up mocks for five other systems.
A mediator — Components send messages to one coordinator
The Mediator pattern introduces a single coordinator that all components talk to. Instead of calling each other directly, components raise events or send requests to the mediator, which then decides what to do and calls the appropriate handlers. Components become loosely coupled—they don't know about each other.
// With a mediator — loose coupling
interface OrderMediator {
handle(command: OrderCommand): Promise;
}
abstract class OrderCommand {
constructor(readonly timestamp: Date = new Date()) {}
}
class CheckoutCommand extends OrderCommand {
constructor(readonly items: OrderItem[], readonly card: CreditCard) {
super();
}
}
class OrderMediatorImpl implements OrderMediator {
constructor(
private inventory: Inventory,
private payment: Payment,
private shipping: Shipping,
private notification: Notification,
private audit: Audit
) {}
async handle(command: OrderCommand): Promise {
if (command instanceof CheckoutCommand) {
const { items, card } = command;
const available = await this.inventory.checkStock(items);
if (!available) throw new Error('Out of stock');
const charged = await this.payment.charge(card, this.calculateTotal(items));
if (!charged) throw new Error('Payment failed');
const shippingCost = await this.shipping.calculateCost(items);
await this.notification.sendReceipt(charged);
await this.audit.log('checkout', { items, amount: charged });
}
}
private calculateTotal(items: OrderItem[]): number {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
}
// Cart now has zero dependencies on other services
class Cart {
constructor(private mediator: OrderMediator) {}
async checkout(items: OrderItem[], card: CreditCard) {
await this.mediator.handle(new CheckoutCommand(items, card));
}
}
Now Cart only knows about the mediator. If we add an Analytics service later, we don't touch Cart—we just add a handler in the mediator. Each service can be tested in isolation. The orchestration logic lives in one place where we can see the entire flow.
MediatR is this pattern — Requests are dispatched to handlers without direct references
In the .NET world, MediatR is the industry-standard implementation of the Mediator pattern. It's a library that lets you send requests and publish notifications without hard dependencies on handlers. Handlers register themselves, and the mediator dispatches to them automatically.
Here's how a real-world MediatR flow looks in a .NET e-commerce application:
// MediatR command (C# style, shown in TypeScript for familiarity)
class CreateOrderCommand {
constructor(
readonly userId: string,
readonly items: OrderLineItem[],
readonly deliveryAddress: Address
) {}
}
class OrderCreatedEvent {
constructor(
readonly orderId: string,
readonly userId: string,
readonly amount: number
) {}
}
// Handler 1: Persist the order
class CreateOrderHandler {
async handle(command: CreateOrderCommand): Promise {
const orderId = generateId();
await this.db.orders.insert({
id: orderId,
userId: command.userId,
items: command.items,
address: command.deliveryAddress,
createdAt: new Date()
});
return orderId;
}
}
// Handler 2: Reserve inventory (notification handler)
class ReserveInventoryHandler {
async handle(event: OrderCreatedEvent): Promise {
for (const item of event.items) {
await this.inventory.reserve(item.productId, item.quantity);
}
}
}
// Handler 3: Send confirmation email (notification handler)
class SendOrderConfirmationHandler {
async handle(event: OrderCreatedEvent): Promise {
const user = await this.users.findById(event.userId);
await this.mailer.send({
to: user.email,
subject: 'Order confirmed',
body: `Your order ${event.orderId} for $${event.amount} is confirmed.`
});
}
}
// Handler 4: Track in analytics (notification handler)
class TrackOrderCreatedHandler {
async handle(event: OrderCreatedEvent): Promise {
await this.analytics.track('order.created', {
orderId: event.orderId,
amount: event.amount
});
}
}
// Application service uses the mediator
class CreateOrderService {
constructor(private mediator: IMediator) {}
async execute(command: CreateOrderCommand): Promise {
// Send the command; the mediator finds CreateOrderHandler
const orderId = await this.mediator.send(command);
// Publish the event; the mediator finds all notification handlers
const evt = new OrderCreatedEvent(orderId, command.userId, 100);
await this.mediator.publish(evt);
return orderId;
}
}
The beauty of this approach is that when a new requirement arrives—say, "integrate with a fulfillment API"—you just write a new handler for OrderCreatedEvent. The service layer doesn't change. The command handler doesn't change. You're extending behavior without modifying existing code (Open/Closed Principle).
In a NestJS application, similar patterns emerge through dependency injection and event emitters, though libraries like nestjs-cqrs bring explicit command and event patterns similar to MediatR.
Don't hide everything — Too much indirection makes flow hard to follow
The mediator pattern is powerful, but it has a dark side: over-indirection. If every single interaction goes through the mediator, the codebase becomes difficult to trace. A junior developer looking at CreateOrderService sees a call to mediator.send() and has to hunt through files to find which handler actually executes.
Use the mediator for cross-cutting orchestration concerns, not for every method call. A good rule of thumb:
- Use the mediator: Complex workflows with multiple components (order checkout, user registration with email verification and analytics), business transactions that may succeed or fail as a unit, operations that trigger side effects.
- Don't use the mediator: Simple value calculations, getters/queries that don't change state, tight operations within a single domain object.
For example, in an order system, you'd use the mediator for the checkout command. But when OrderCalculator computes tax, that's a simple method call—no need for indirection:
// Good: Clear responsibility, no indirection needed
class OrderCalculator {
calculateTax(subtotal: number, jurisdiction: string): number {
const rate = this.taxRates.getRate(jurisdiction);
return subtotal * rate;
}
}
// Better used directly from the checkout handler
const tax = calculator.calculateTax(subtotal, 'CA');
// Over-engineered: Mediator for everything
class TaxQueryCommand { /* ... */ }
const tax = await mediator.send(new TaxQueryCommand(subtotal, 'CA'));
Also be cautious with fire-and-forget event handlers. If an event handler silently fails, you might not notice. In a payment system, if the "send confirmation email" handler fails, the order is already created. Use handler result tracking:
// Better: Track which handlers succeeded/failed
const results = await this.mediator.publish(event);
const failedHandlers = results.filter(r => !r.success);
if (failedHandlers.length > 0) {
this.logger.error('Event handlers failed', {
event: event.constructor.name,
failures: failedHandlers
});
// Retry or alert ops
}
Common mistakes
- Making the mediator a god object: If your mediator knows about 15 different commands and decides the logic for all of them, you've just moved the problem. Keep handlers separate; the mediator should dispatch, not orchestrate.
- Hiding async failures: Event handlers that fire asynchronously can fail silently. Always track handler results and implement retry logic for critical operations.
- Using mediator for simple calls: Not every interaction needs indirection. If
AcallsBdirectly as part of normal flow and there's no orchestration concern, keep it direct. - Circular dependencies in handlers: If handler A publishes an event that triggers handler B, which publishes an event that triggers handler A, you've created a loop. Be explicit about event flow in documentation.
- Ignoring command validation: Commands should be immutable and valid when constructed. Validate in the handler, not in the mediator—the mediator just dispatches.
Key takeaways
- The Mediator pattern centralizes component communication, reducing coupling and making workflows easier to understand.
- MediatR and similar libraries provide production-ready implementations with command/query and event-driven semantics.
- Use mediators for complex orchestration—order checkout, user registration, multi-step workflows. Skip it for simple value calculations and direct domain interactions.
- Watch out for over-indirection; the mediator should clarify flow, not obscure it.
- Always handle and log failures in event handlers; asynchronous execution makes failures easy to miss.
- The mediator itself should be thin; most logic belongs in handlers that are easy to locate and test.






Comments
Loading comments…