Part VII: The Mediator Pattern
"Decoupling is not about removing dependencies -- it is about making them explicit and injectable."
Every non-trivial application has a dispatch problem. A request arrives -- an HTTP POST, a queue message, a gRPC call -- and something needs to figure out which code handles it. In the simplest case, the controller method that receives the request also contains the logic that processes it: validate the input, check permissions, open a transaction, call domain methods, persist the result, publish events, log the outcome. This works for the first three endpoints. By the twentieth endpoint, the controller is 600 lines long, every method looks structurally identical except for the business logic in the middle, and the cross-cutting concerns -- validation, logging, authorization, transactions -- are copy-pasted across every action method with slight variations in each copy.
The Mediator pattern solves this by introducing a single dispatch point between the caller (the controller) and the handler (the business logic). The caller constructs a request object and sends it to the mediator. The mediator finds the handler registered for that request type and invokes it. Between the send and the handle, the mediator runs a pipeline of behaviors -- middleware components that implement cross-cutting concerns like validation, logging, authorization, and transaction management. The caller does not know which handler will run. The handler does not know which behaviors wrapped it. The behaviors do not know which request type they are wrapping (because they are generic). Each component knows only its own contract and nothing else.
This is not a new pattern. The Gang of Four described it in 1994. MediatR brought it to the .NET mainstream in 2016. What FrenchExDev's implementation adds is not a new idea but a sharper design: CQRS marker interfaces that distinguish commands from queries at the type level, a notification system with three explicit publish strategies, a contracts-only library that defines interfaces without shipping a runtime dispatcher, and a FakeMediator test double that makes mediator-dependent code trivially testable without mocking frameworks.
This chapter covers the complete FrenchExDev.Net.Mediator package: the IMediator interface, the request hierarchy (IRequest<T>, ICommand<T>, IQuery<T>), the handler contract (IRequestHandler<TRequest, TResult>), the pipeline middleware (IBehavior<TRequest, TResult>), the notification system (INotification, INotificationHandler<T>, PublishStrategy), the FakeMediator test double, DI registration with [Injectable], the contracts-only design philosophy, a real-world order processing example, and an honest comparison with MediatR.
The Problem: Fat Controllers and Cross-Cutting Concerns
Here is a typical ASP.NET controller action that handles creating an order. Count the responsibilities:
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
private readonly IOrderRepository _orders;
private readonly ICustomerRepository _customers;
private readonly IValidator<CreateOrderRequest> _validator;
private readonly ILogger<OrdersController> _logger;
private readonly IAuthorizationService _auth;
private readonly AppDbContext _db;
private readonly IEmailService _email;
private readonly IAnalyticsService _analytics;
// 8-parameter constructor omitted for brevity
[HttpPost]
public async Task<IActionResult> Create(
[FromBody] CreateOrderRequest request, CancellationToken ct)
{
// 1. Logging
_logger.LogInformation("Creating order for {CustomerId}",
request.CustomerId);
// 2. Validation
var validation = _validator.Validate(request);
if (!validation.IsValid) return BadRequest(validation.Errors);
// 3. Authorization
var authResult = await _auth.AuthorizeAsync(
User, request, "CanCreateOrder");
if (!authResult.Succeeded) return Forbid();
// 4. Business logic
var customer = await _customers.GetByIdAsync(
request.CustomerId, ct);
if (customer is null) return NotFound();
var order = Order.Create(customer,
request.Items.Select(i => new OrderLine(i.Sku, i.Quantity)),
request.ShippingAddress);
// 5. Persistence (in a transaction)
await using var tx = await _db.Database.BeginTransactionAsync(ct);
try
{
await _orders.AddAsync(order, ct);
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
catch { await tx.RollbackAsync(ct); throw; }
// 6. Side effects
await _email.SendOrderConfirmationAsync(order, ct);
await _analytics.TrackOrderCreatedAsync(order, ct);
// 7. Logging (again)
_logger.LogInformation("Order {OrderId} created", order.Id);
return CreatedAtAction(
nameof(GetById), new { id = order.Id }, order.ToDto());
}
}[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
private readonly IOrderRepository _orders;
private readonly ICustomerRepository _customers;
private readonly IValidator<CreateOrderRequest> _validator;
private readonly ILogger<OrdersController> _logger;
private readonly IAuthorizationService _auth;
private readonly AppDbContext _db;
private readonly IEmailService _email;
private readonly IAnalyticsService _analytics;
// 8-parameter constructor omitted for brevity
[HttpPost]
public async Task<IActionResult> Create(
[FromBody] CreateOrderRequest request, CancellationToken ct)
{
// 1. Logging
_logger.LogInformation("Creating order for {CustomerId}",
request.CustomerId);
// 2. Validation
var validation = _validator.Validate(request);
if (!validation.IsValid) return BadRequest(validation.Errors);
// 3. Authorization
var authResult = await _auth.AuthorizeAsync(
User, request, "CanCreateOrder");
if (!authResult.Succeeded) return Forbid();
// 4. Business logic
var customer = await _customers.GetByIdAsync(
request.CustomerId, ct);
if (customer is null) return NotFound();
var order = Order.Create(customer,
request.Items.Select(i => new OrderLine(i.Sku, i.Quantity)),
request.ShippingAddress);
// 5. Persistence (in a transaction)
await using var tx = await _db.Database.BeginTransactionAsync(ct);
try
{
await _orders.AddAsync(order, ct);
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
catch { await tx.RollbackAsync(ct); throw; }
// 6. Side effects
await _email.SendOrderConfirmationAsync(order, ct);
await _analytics.TrackOrderCreatedAsync(order, ct);
// 7. Logging (again)
_logger.LogInformation("Order {OrderId} created", order.Id);
return CreatedAtAction(
nameof(GetById), new { id = order.Id }, order.ToDto());
}
}The constructor takes eight dependencies, most of which exist only for cross-cutting concerns. The action method has seven numbered responsibilities, only one of which -- step 4, the business logic -- is unique to this endpoint. Steps 1, 2, 3, 5, 6, and 7 will be duplicated (with minor variations) in every other action method in every other controller.
This code has five specific problems:
Problem 1: Untestable. Unit-testing Create requires mocking eight dependencies. The mocking code will be longer than the production code, and the test must downcast IActionResult to inspect the response.
Problem 2: Duplicated cross-cutting concerns. The validation-then-authorization-then-transaction pattern is the same in every write endpoint, but each inline copy is slightly different. One endpoint logs before validation; another logs after. One forgets the transaction entirely.
Problem 3: Tight coupling to infrastructure. The business logic is entangled with HTTP concerns (IActionResult), persistence concerns (transaction management), and notification concerns (email and analytics). You cannot reuse the order creation logic from a queue consumer without extracting it.
Problem 4: Noisy constructors. Eight parameters, half for cross-cutting concerns that could be handled generically. The constructor tells you everything the endpoint touches, not what it does.
Problem 5: Fragile ordering. Validation must precede authorization, which must precede the transaction. But this ordering is implicit in the method body. Nothing in the type system enforces it.
The Mediator pattern eliminates all five problems. Here is the same endpoint with IMediator:
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
private readonly IMediator _mediator;
public OrdersController(IMediator mediator) => _mediator = mediator;
[HttpPost]
public async Task<IActionResult> Create(
[FromBody] CreateOrderRequest request,
CancellationToken ct)
{
var result = await _mediator.SendAsync(
new CreateOrderCommand(
request.CustomerId,
request.Items,
request.ShippingAddress),
ct);
return result.Match(
success: order => CreatedAtAction(
nameof(GetById), new { id = order.Id }, order),
failure: error => error.ToProblemDetails());
}
}[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
private readonly IMediator _mediator;
public OrdersController(IMediator mediator) => _mediator = mediator;
[HttpPost]
public async Task<IActionResult> Create(
[FromBody] CreateOrderRequest request,
CancellationToken ct)
{
var result = await _mediator.SendAsync(
new CreateOrderCommand(
request.CustomerId,
request.Items,
request.ShippingAddress),
ct);
return result.Match(
success: order => CreatedAtAction(
nameof(GetById), new { id = order.Id }, order),
failure: error => error.ToProblemDetails());
}
}One constructor parameter. No validation, no authorization, no transaction, no logging, no email, no analytics. The controller's only job is to translate between HTTP and the domain command. All cross-cutting concerns live in the behavior pipeline, applied automatically to every request that passes through the mediator. The business logic lives in a handler class that takes only the dependencies it actually needs. And the test for the handler does not need an HTTP context, an IActionResult, or eight mocks.
The IMediator Interface
The mediator is the single dispatch point. It has exactly two methods:
public interface IMediator
{
Task<TResult> SendAsync<TResult>(
IRequest<TResult> request,
CancellationToken ct = default);
Task PublishAsync(
INotification notification,
CancellationToken ct = default,
PublishStrategy strategy = PublishStrategy.Sequential);
}public interface IMediator
{
Task<TResult> SendAsync<TResult>(
IRequest<TResult> request,
CancellationToken ct = default);
Task PublishAsync(
INotification notification,
CancellationToken ct = default,
PublishStrategy strategy = PublishStrategy.Sequential);
}SendAsync dispatches a request to exactly one handler and returns a result. This is the command/query path. Every request has exactly one handler. If zero handlers are registered, the mediator throws. If two handlers are registered for the same request type, the DI container throws at registration time (because the handler is registered as IRequestHandler<TRequest, TResult>, which is a one-to-one mapping).
PublishAsync dispatches a notification to zero or more handlers. This is the event path. A notification can have no handlers (it is silently ignored), one handler, or twenty handlers. The PublishStrategy parameter controls how multiple handlers are invoked: sequentially, in parallel, or fire-and-forget.
The asymmetry is intentional. Commands and queries are one-to-one: one request, one handler, one result. Notifications are one-to-many: one event, many subscribers, no result. This reflects the fundamental difference between asking a question (which expects exactly one answer) and announcing a fact (which any number of listeners may care about).
The diagram shows the complete type hierarchy. ICommand<TResult> and IQuery<TResult> both extend IRequest<TResult>. The mediator dispatches requests to handlers and wraps them with behaviors. Notifications go to notification handlers. No type references any other type directly. The mediator is the only coupling point.
CQRS with Marker Interfaces
CQRS -- Command Query Responsibility Segregation -- is a pattern that separates read operations from write operations. The idea is simple: a method that changes state should not return data, and a method that returns data should not change state. In practice, the separation is rarely that strict (commands often return the ID of the created entity, or a Result<T> indicating success or failure), but the conceptual split between "do something" and "ask something" is valuable.
FrenchExDev's mediator enforces this split at the type level with three marker interfaces:
IRequest<TResult> -- The Base Marker
public interface IRequest<TResult> { }public interface IRequest<TResult> { }This is the base interface for all request types. It has no members. It exists solely to carry the TResult type parameter, which tells the mediator (and the developer, and the compiler) what type the handler will return.
You would use IRequest<TResult> directly only in rare cases where the request is neither clearly a command nor clearly a query -- for example, a request that conditionally mutates state based on a read. In practice, this is uncommon. Most requests are clearly one or the other.
ICommand<TResult> -- The Write Side
public interface ICommand<TResult> : IRequest<TResult> { }public interface ICommand<TResult> : IRequest<TResult> { }A command represents an intent to change state. Creating an order. Updating a customer's address. Cancelling a subscription. Approving a loan application. The name is a verb phrase: CreateOrder, UpdateAddress, CancelSubscription, ApproveLoan.
Commands typically return Result<T> -- a success value or a failure with error details. They do not return the full entity (that is a query's job). They return the minimum information the caller needs to know whether the operation succeeded and how to reference the result:
public sealed record CreateOrderCommand(
string CustomerId,
IReadOnlyList<OrderLineItem> Items,
Address ShippingAddress) : ICommand<Result<OrderId>>;
public sealed record CancelSubscriptionCommand(
SubscriptionId SubscriptionId,
string Reason) : ICommand<Result>;
public sealed record UpdateCustomerAddressCommand(
CustomerId CustomerId,
Address NewAddress) : ICommand<Result>;public sealed record CreateOrderCommand(
string CustomerId,
IReadOnlyList<OrderLineItem> Items,
Address ShippingAddress) : ICommand<Result<OrderId>>;
public sealed record CancelSubscriptionCommand(
SubscriptionId SubscriptionId,
string Reason) : ICommand<Result>;
public sealed record UpdateCustomerAddressCommand(
CustomerId CustomerId,
Address NewAddress) : ICommand<Result>;Notice the naming convention. The command name describes the action. The record properties are the parameters of the action. The TResult type parameter describes what the caller gets back. Result<OrderId> means "this command creates something and returns its ID on success." Result (without a type parameter) means "this command succeeds or fails, and the caller does not need a return value beyond that."
IQuery<TResult> -- The Read Side
public interface IQuery<TResult> : IRequest<TResult> { }public interface IQuery<TResult> : IRequest<TResult> { }A query represents a request for data. It does not change state. It does not trigger side effects. It reads from the database (or cache, or external service) and returns a result.
Queries typically return DTOs or read models, not domain entities. The handler projects the data into the shape the consumer needs:
public sealed record GetOrderByIdQuery(
OrderId OrderId) : IQuery<Result<OrderDto>>;
public sealed record ListOrdersByCustomerQuery(
CustomerId CustomerId,
int Page,
int PageSize) : IQuery<Result<PagedList<OrderSummaryDto>>>;
public sealed record GetDashboardMetricsQuery(
DateTimeOffset From,
DateTimeOffset To) : IQuery<Result<DashboardMetrics>>;public sealed record GetOrderByIdQuery(
OrderId OrderId) : IQuery<Result<OrderDto>>;
public sealed record ListOrdersByCustomerQuery(
CustomerId CustomerId,
int Page,
int PageSize) : IQuery<Result<PagedList<OrderSummaryDto>>>;
public sealed record GetDashboardMetricsQuery(
DateTimeOffset From,
DateTimeOffset To) : IQuery<Result<DashboardMetrics>>;The naming convention is different from commands. Query names describe what you are asking for, not what you are doing. GetOrderById. ListOrdersByCustomer. GetDashboardMetrics.
Why the Split Matters
The marker interfaces add no runtime behavior. They compile to the same IL. Their value is entirely at the design level:
Semantic clarity. ICommand<Result<OrderId>> tells you the request mutates state. IQuery<Result<OrderDto>> tells you it reads data. Without markers, you must read the handler to know.
Pipeline differentiation. Behaviors can target commands and queries separately. A TransactionBehavior should wrap commands but not queries. A CachingBehavior should wrap queries but not commands. The type system enforces the separation:
// This behavior only wraps commands
public class TransactionBehavior<TRequest, TResult>
: IBehavior<TRequest, TResult>
where TRequest : ICommand<TResult> { /* ... */ }
// This behavior only wraps queries
public class CachingBehavior<TRequest, TResult>
: IBehavior<TRequest, TResult>
where TRequest : IQuery<TResult> { /* ... */ }// This behavior only wraps commands
public class TransactionBehavior<TRequest, TResult>
: IBehavior<TRequest, TResult>
where TRequest : ICommand<TResult> { /* ... */ }
// This behavior only wraps queries
public class CachingBehavior<TRequest, TResult>
: IBehavior<TRequest, TResult>
where TRequest : IQuery<TResult> { /* ... */ }Architectural enforcement. A query handler that calls SaveChangesAsync is a code smell that is easy to spot in review because the IQuery marker signals "read-only."
The CQRS flow diagram makes the separation visual. Commands flow through the write side: they hit a handler, modify an aggregate, and produce domain events. Queries flow through the read side: they hit a handler that reads from a view or read model. Both sides use the same IMediator.SendAsync entry point -- the only difference is the marker interface on the request type.
Request Handlers: IRequestHandler<TRequest, TResult>
Every request needs exactly one handler. The handler contract is simple:
public interface IRequestHandler<in TRequest, TResult>
where TRequest : IRequest<TResult>
{
Task<TResult> HandleAsync(TRequest request, CancellationToken ct = default);
}public interface IRequestHandler<in TRequest, TResult>
where TRequest : IRequest<TResult>
{
Task<TResult> HandleAsync(TRequest request, CancellationToken ct = default);
}The generic constraint where TRequest : IRequest<TResult> ties the request type to its result type. You cannot accidentally create a handler that returns string for a request that declares IRequest<int>. The compiler catches the mismatch.
The in variance on TRequest means the handler accepts the declared request type or any of its base types. In practice, request types are sealed records and variance is not exercised, but the variance marker communicates intent: the handler consumes the request, it does not produce one.
A Command Handler
Here is the handler for the CreateOrderCommand shown earlier:
[Injectable(Lifetime.Scoped)]
public sealed class CreateOrderHandler
: IRequestHandler<CreateOrderCommand, Result<OrderId>>
{
private readonly IOrderRepository _orders;
private readonly ICustomerRepository _customers;
private readonly IClock _clock;
public CreateOrderHandler(
IOrderRepository orders,
ICustomerRepository customers,
IClock clock)
{
_orders = orders;
_customers = customers;
_clock = clock;
}
public async Task<Result<OrderId>> HandleAsync(
CreateOrderCommand command,
CancellationToken ct = default)
{
var customer = await _customers.GetByIdAsync(
command.CustomerId, ct);
if (customer is null)
return Result.Failure<OrderId>(
Error.NotFound($"Customer {command.CustomerId} not found."));
var order = Order.Create(
customer,
command.Items.Select(
i => new OrderLine(i.Sku, i.Quantity)),
command.ShippingAddress,
_clock.UtcNow);
await _orders.AddAsync(order, ct);
return Result.Success(order.Id);
}
}[Injectable(Lifetime.Scoped)]
public sealed class CreateOrderHandler
: IRequestHandler<CreateOrderCommand, Result<OrderId>>
{
private readonly IOrderRepository _orders;
private readonly ICustomerRepository _customers;
private readonly IClock _clock;
public CreateOrderHandler(
IOrderRepository orders,
ICustomerRepository customers,
IClock clock)
{
_orders = orders;
_customers = customers;
_clock = clock;
}
public async Task<Result<OrderId>> HandleAsync(
CreateOrderCommand command,
CancellationToken ct = default)
{
var customer = await _customers.GetByIdAsync(
command.CustomerId, ct);
if (customer is null)
return Result.Failure<OrderId>(
Error.NotFound($"Customer {command.CustomerId} not found."));
var order = Order.Create(
customer,
command.Items.Select(
i => new OrderLine(i.Sku, i.Quantity)),
command.ShippingAddress,
_clock.UtcNow);
await _orders.AddAsync(order, ct);
return Result.Success(order.Id);
}
}Notice what this handler does not do. It does not validate the command (that is the ValidationBehavior's job). It does not check authorization (that is the AuthorizationBehavior's job). It does not manage a transaction (that is the TransactionBehavior's job). It does not log (that is the LoggingBehavior's job). It does exactly one thing: create an order from a customer and line items and return the order ID. The handler takes three dependencies -- the two repositories it actually needs and the clock -- instead of the eight dependencies the fat controller required.
The [Injectable(Lifetime.Scoped)] attribute registers the handler in the DI container via source generation. No manual services.AddScoped<IRequestHandler<CreateOrderCommand, Result<OrderId>>, CreateOrderHandler>() call. The source generator discovers the attribute at compile time and emits the registration code.
A Query Handler
Query handlers follow the same pattern but typically project data into DTOs instead of modifying aggregates:
[Injectable(Lifetime.Scoped)]
public sealed class GetOrderByIdHandler
: IRequestHandler<GetOrderByIdQuery, Result<OrderDto>>
{
private readonly IOrderRepository _orders;
public GetOrderByIdHandler(IOrderRepository orders)
=> _orders = orders;
public async Task<Result<OrderDto>> HandleAsync(
GetOrderByIdQuery query,
CancellationToken ct = default)
{
var order = await _orders.GetByIdAsync(query.OrderId, ct);
if (order is null)
return Result.Failure<OrderDto>(
Error.NotFound($"Order {query.OrderId} not found."));
return Result.Success(new OrderDto(
Id: order.Id,
CustomerId: order.CustomerId,
CustomerName: order.CustomerName,
Items: order.Lines.Select(l => new OrderLineDto(
Sku: l.Sku,
Quantity: l.Quantity,
UnitPrice: l.UnitPrice)).ToList(),
Total: order.Total,
Status: order.Status.ToString(),
CreatedAt: order.CreatedAt));
}
}[Injectable(Lifetime.Scoped)]
public sealed class GetOrderByIdHandler
: IRequestHandler<GetOrderByIdQuery, Result<OrderDto>>
{
private readonly IOrderRepository _orders;
public GetOrderByIdHandler(IOrderRepository orders)
=> _orders = orders;
public async Task<Result<OrderDto>> HandleAsync(
GetOrderByIdQuery query,
CancellationToken ct = default)
{
var order = await _orders.GetByIdAsync(query.OrderId, ct);
if (order is null)
return Result.Failure<OrderDto>(
Error.NotFound($"Order {query.OrderId} not found."));
return Result.Success(new OrderDto(
Id: order.Id,
CustomerId: order.CustomerId,
CustomerName: order.CustomerName,
Items: order.Lines.Select(l => new OrderLineDto(
Sku: l.Sku,
Quantity: l.Quantity,
UnitPrice: l.UnitPrice)).ToList(),
Total: order.Total,
Status: order.Status.ToString(),
CreatedAt: order.CreatedAt));
}
}This handler takes a single dependency: the order repository. It reads an order, maps it to a DTO, and returns it. No mutations. No side effects. One dependency. Trivially testable.
Handler Lifecycle and the One-Handler Rule
Handlers are scoped by default (new instance per HTTP request or DI scope), which is correct because they typically depend on scoped services like DbContext. If a handler is stateless and has no scoped dependencies, register it as singleton: [Injectable(Lifetime.Singleton)]. The lifetime is explicit in the attribute -- no magic conventions.
Every request type must have exactly one handler. This is enforced by the DI container (because the handler is registered as a specific closed generic type) and by the mediator (which resolves IRequestHandler<TRequest, TResult> from the container and expects exactly one instance).
If you need multiple handlers for the same event, use notifications. If you need to compose multiple operations into a single request, compose them in the handler. The one-handler rule keeps the dispatch simple and predictable: given a request type, you can always find its handler by searching for the class that implements IRequestHandler<ThatRequestType, TResult>.
The Pipeline: IBehavior<TRequest, TResult>
The pipeline is where the Mediator pattern earns its keep. Without the pipeline, the mediator is just an indirection layer -- a factory method with extra steps. With the pipeline, the mediator becomes a composition engine for cross-cutting concerns.
The Middleware Pattern
The IBehavior interface follows the same pattern as ASP.NET middleware: each behavior receives the request and a next delegate that invokes the next behavior in the pipeline (or the handler, if this is the last behavior):
public interface IBehavior<in TRequest, TResult>
where TRequest : IRequest<TResult>
{
Task<TResult> HandleAsync(
TRequest request,
Func<Task<TResult>> next,
CancellationToken ct = default);
}public interface IBehavior<in TRequest, TResult>
where TRequest : IRequest<TResult>
{
Task<TResult> HandleAsync(
TRequest request,
Func<Task<TResult>> next,
CancellationToken ct = default);
}The next parameter is the key. A behavior can do work before calling next(), after calling next(), or instead of calling next() (short-circuiting the pipeline). This is the Russian nesting doll pattern: each behavior wraps the next, and the handler sits at the center.
Here is the simplest possible behavior -- one that does nothing but pass through:
public class PassThroughBehavior<TRequest, TResult>
: IBehavior<TRequest, TResult>
where TRequest : IRequest<TResult>
{
public Task<TResult> HandleAsync(
TRequest request,
Func<Task<TResult>> next,
CancellationToken ct = default)
{
return next();
}
}public class PassThroughBehavior<TRequest, TResult>
: IBehavior<TRequest, TResult>
where TRequest : IRequest<TResult>
{
public Task<TResult> HandleAsync(
TRequest request,
Func<Task<TResult>> next,
CancellationToken ct = default)
{
return next();
}
}And here is the general pattern for a behavior that does work before and after:
public Task<TResult> HandleAsync(
TRequest request,
Func<Task<TResult>> next,
CancellationToken ct = default)
{
// Before: runs before the handler (and all inner behaviors)
// ...
var result = await next();
// After: runs after the handler (and all inner behaviors)
// ...
return result;
}public Task<TResult> HandleAsync(
TRequest request,
Func<Task<TResult>> next,
CancellationToken ct = default)
{
// Before: runs before the handler (and all inner behaviors)
// ...
var result = await next();
// After: runs after the handler (and all inner behaviors)
// ...
return result;
}And here is the pattern for a behavior that short-circuits (returns without calling next()):
public Task<TResult> HandleAsync(
TRequest request,
Func<Task<TResult>> next,
CancellationToken ct = default)
{
if (someConditionFails)
return someFailureResult; // Handler never executes
return next();
}public Task<TResult> HandleAsync(
TRequest request,
Func<Task<TResult>> next,
CancellationToken ct = default)
{
if (someConditionFails)
return someFailureResult; // Handler never executes
return next();
}Short-circuiting is how validation and authorization behaviors reject invalid or unauthorized requests without executing the handler. The handler never knows the request was rejected -- it simply never runs.
Common Behaviors
The four behaviors shown below are the ones you will implement in almost every project that uses the mediator. They form a standard pipeline: logging wraps everything, validation checks the request shape, authorization checks permissions, and a transaction wraps the handler's persistence calls.
1. LoggingBehavior
The logging behavior wraps every request with structured log entries. It logs the request type and key properties before execution, and the outcome (success or failure) and elapsed time after execution:
[Injectable(Lifetime.Singleton)]
public sealed class LoggingBehavior<TRequest, TResult>
: IBehavior<TRequest, TResult>
where TRequest : IRequest<TResult>
{
private readonly ILogger<LoggingBehavior<TRequest, TResult>> _logger;
private readonly IClock _clock;
public LoggingBehavior(
ILogger<LoggingBehavior<TRequest, TResult>> logger,
IClock clock)
{
_logger = logger;
_clock = clock;
}
public async Task<TResult> HandleAsync(
TRequest request,
Func<Task<TResult>> next,
CancellationToken ct = default)
{
var requestName = typeof(TRequest).Name;
_logger.LogInformation(
"Handling {RequestName}: {@Request}",
requestName, request);
var start = _clock.UtcNow;
try
{
var result = await next();
var elapsed = _clock.UtcNow - start;
_logger.LogInformation(
"Handled {RequestName} in {ElapsedMs}ms: {@Result}",
requestName, elapsed.TotalMilliseconds, result);
return result;
}
catch (Exception ex)
{
var elapsed = _clock.UtcNow - start;
_logger.LogError(ex,
"Failed {RequestName} after {ElapsedMs}ms",
requestName, elapsed.TotalMilliseconds);
throw;
}
}
}[Injectable(Lifetime.Singleton)]
public sealed class LoggingBehavior<TRequest, TResult>
: IBehavior<TRequest, TResult>
where TRequest : IRequest<TResult>
{
private readonly ILogger<LoggingBehavior<TRequest, TResult>> _logger;
private readonly IClock _clock;
public LoggingBehavior(
ILogger<LoggingBehavior<TRequest, TResult>> logger,
IClock clock)
{
_logger = logger;
_clock = clock;
}
public async Task<TResult> HandleAsync(
TRequest request,
Func<Task<TResult>> next,
CancellationToken ct = default)
{
var requestName = typeof(TRequest).Name;
_logger.LogInformation(
"Handling {RequestName}: {@Request}",
requestName, request);
var start = _clock.UtcNow;
try
{
var result = await next();
var elapsed = _clock.UtcNow - start;
_logger.LogInformation(
"Handled {RequestName} in {ElapsedMs}ms: {@Result}",
requestName, elapsed.TotalMilliseconds, result);
return result;
}
catch (Exception ex)
{
var elapsed = _clock.UtcNow - start;
_logger.LogError(ex,
"Failed {RequestName} after {ElapsedMs}ms",
requestName, elapsed.TotalMilliseconds);
throw;
}
}
}This behavior is generic over all request types. It does not know whether it is wrapping a CreateOrderCommand or a GetDashboardMetricsQuery. It just logs the type name, the request payload (via structured logging), the result, and the elapsed time. Because it uses IClock instead of Stopwatch, the elapsed time is testable with FakeClock.
The behavior is registered as a singleton because it has no scoped dependencies. The ILogger is thread-safe, and the IClock is stateless (in production -- FakeClock in tests is mutable, but that is a testing concern, not a registration concern).
2. ValidationBehavior
The validation behavior checks the request against registered validators and short-circuits with a failure result if validation fails:
[Injectable(Lifetime.Scoped)]
public sealed class ValidationBehavior<TRequest, TResult>
: IBehavior<TRequest, TResult>
where TRequest : IRequest<TResult>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(
IEnumerable<IValidator<TRequest>> validators)
{
_validators = validators;
}
public async Task<TResult> HandleAsync(
TRequest request,
Func<Task<TResult>> next,
CancellationToken ct = default)
{
if (!_validators.Any())
return await next();
var errors = new List<ValidationError>();
foreach (var validator in _validators)
{
var result = await validator.ValidateAsync(request, ct);
if (!result.IsValid)
{
errors.AddRange(result.Errors.Select(e =>
new ValidationError(e.PropertyName, e.ErrorMessage)));
}
}
if (errors.Count > 0)
{
// Short-circuit: return failure without calling next()
return CreateValidationFailure(errors);
}
return await next();
}
private static TResult CreateValidationFailure(
List<ValidationError> errors)
{
// Works because TResult is Result<T> or Result,
// which has a static Failure factory method
if (typeof(TResult).IsGenericType &&
typeof(TResult).GetGenericTypeDefinition() == typeof(Result<>))
{
var innerType = typeof(TResult).GetGenericArguments()[0];
var failureMethod = typeof(Result)
.GetMethod(nameof(Result.Failure), 1,
new[] { typeof(Error) })!
.MakeGenericMethod(innerType);
return (TResult)failureMethod.Invoke(null,
new object[]
{
Error.Validation("Validation failed.", errors)
})!;
}
throw new InvalidOperationException(
$"Cannot create validation failure for type {typeof(TResult)}. " +
$"Request handlers should return Result<T> or Result.");
}
}[Injectable(Lifetime.Scoped)]
public sealed class ValidationBehavior<TRequest, TResult>
: IBehavior<TRequest, TResult>
where TRequest : IRequest<TResult>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(
IEnumerable<IValidator<TRequest>> validators)
{
_validators = validators;
}
public async Task<TResult> HandleAsync(
TRequest request,
Func<Task<TResult>> next,
CancellationToken ct = default)
{
if (!_validators.Any())
return await next();
var errors = new List<ValidationError>();
foreach (var validator in _validators)
{
var result = await validator.ValidateAsync(request, ct);
if (!result.IsValid)
{
errors.AddRange(result.Errors.Select(e =>
new ValidationError(e.PropertyName, e.ErrorMessage)));
}
}
if (errors.Count > 0)
{
// Short-circuit: return failure without calling next()
return CreateValidationFailure(errors);
}
return await next();
}
private static TResult CreateValidationFailure(
List<ValidationError> errors)
{
// Works because TResult is Result<T> or Result,
// which has a static Failure factory method
if (typeof(TResult).IsGenericType &&
typeof(TResult).GetGenericTypeDefinition() == typeof(Result<>))
{
var innerType = typeof(TResult).GetGenericArguments()[0];
var failureMethod = typeof(Result)
.GetMethod(nameof(Result.Failure), 1,
new[] { typeof(Error) })!
.MakeGenericMethod(innerType);
return (TResult)failureMethod.Invoke(null,
new object[]
{
Error.Validation("Validation failed.", errors)
})!;
}
throw new InvalidOperationException(
$"Cannot create validation failure for type {typeof(TResult)}. " +
$"Request handlers should return Result<T> or Result.");
}
}The validation behavior is scoped because it takes IEnumerable<IValidator<TRequest>>, which may include scoped validators. It runs all validators for the request type, collects errors, and either short-circuits with a Result.Failure or calls next() to continue the pipeline.
The CreateValidationFailure helper uses a small amount of reflection to construct the failure result. This is the one place in the library where reflection is unavoidable -- the behavior is generic over TResult, and it needs to construct a failure of the correct type. In practice, TResult is always Result<T> or Result, so the reflection path is exercised exactly once per closed generic type and then JIT-cached.
3. AuthorizationBehavior
The authorization behavior checks whether the current user is permitted to execute the request:
[Injectable(Lifetime.Scoped)]
public sealed class AuthorizationBehavior<TRequest, TResult>
: IBehavior<TRequest, TResult>
where TRequest : ICommand<TResult> // Only commands, not queries
{
private readonly ICurrentUser _currentUser;
private readonly IEnumerable<IAuthorizationRule<TRequest>> _rules;
public AuthorizationBehavior(
ICurrentUser currentUser,
IEnumerable<IAuthorizationRule<TRequest>> rules)
{
_currentUser = currentUser;
_rules = rules;
}
public async Task<TResult> HandleAsync(
TRequest request,
Func<Task<TResult>> next,
CancellationToken ct = default)
{
foreach (var rule in _rules)
{
var allowed = await rule.IsAuthorizedAsync(
_currentUser, request, ct);
if (!allowed)
{
return CreateForbiddenResult(rule.FailureMessage);
}
}
return await next();
}
private static TResult CreateForbiddenResult(string message)
{
// Similar pattern to ValidationBehavior
if (typeof(TResult).IsGenericType &&
typeof(TResult).GetGenericTypeDefinition() == typeof(Result<>))
{
var innerType = typeof(TResult).GetGenericArguments()[0];
var failureMethod = typeof(Result)
.GetMethod(nameof(Result.Failure), 1,
new[] { typeof(Error) })!
.MakeGenericMethod(innerType);
return (TResult)failureMethod.Invoke(null,
new object[] { Error.Forbidden(message) })!;
}
throw new InvalidOperationException(
$"Cannot create forbidden result for type {typeof(TResult)}.");
}
}[Injectable(Lifetime.Scoped)]
public sealed class AuthorizationBehavior<TRequest, TResult>
: IBehavior<TRequest, TResult>
where TRequest : ICommand<TResult> // Only commands, not queries
{
private readonly ICurrentUser _currentUser;
private readonly IEnumerable<IAuthorizationRule<TRequest>> _rules;
public AuthorizationBehavior(
ICurrentUser currentUser,
IEnumerable<IAuthorizationRule<TRequest>> rules)
{
_currentUser = currentUser;
_rules = rules;
}
public async Task<TResult> HandleAsync(
TRequest request,
Func<Task<TResult>> next,
CancellationToken ct = default)
{
foreach (var rule in _rules)
{
var allowed = await rule.IsAuthorizedAsync(
_currentUser, request, ct);
if (!allowed)
{
return CreateForbiddenResult(rule.FailureMessage);
}
}
return await next();
}
private static TResult CreateForbiddenResult(string message)
{
// Similar pattern to ValidationBehavior
if (typeof(TResult).IsGenericType &&
typeof(TResult).GetGenericTypeDefinition() == typeof(Result<>))
{
var innerType = typeof(TResult).GetGenericArguments()[0];
var failureMethod = typeof(Result)
.GetMethod(nameof(Result.Failure), 1,
new[] { typeof(Error) })!
.MakeGenericMethod(innerType);
return (TResult)failureMethod.Invoke(null,
new object[] { Error.Forbidden(message) })!;
}
throw new InvalidOperationException(
$"Cannot create forbidden result for type {typeof(TResult)}.");
}
}Notice the constraint: where TRequest : ICommand<TResult>. This behavior only wraps commands. Queries are not subject to command-level authorization checks (they may have their own read-level authorization, but that is a different concern with different rules). The CQRS marker interfaces make this constraint possible and type-safe.
4. TransactionBehavior
The transaction behavior wraps the handler in a database transaction. Like authorization, it is constrained to ICommand<TResult> -- queries do not need transactions:
[Injectable(Lifetime.Scoped)]
public sealed class TransactionBehavior<TRequest, TResult>
: IBehavior<TRequest, TResult>
where TRequest : ICommand<TResult>
{
private readonly AppDbContext _db;
private readonly ILogger<TransactionBehavior<TRequest, TResult>> _logger;
public TransactionBehavior(AppDbContext db,
ILogger<TransactionBehavior<TRequest, TResult>> logger)
{ _db = db; _logger = logger; }
public async Task<TResult> HandleAsync(
TRequest request, Func<Task<TResult>> next,
CancellationToken ct = default)
{
// Skip if already in a transaction (e.g., saga step)
if (_db.Database.CurrentTransaction is not null)
return await next();
return await _db.Database.CreateExecutionStrategy()
.ExecuteAsync(async () =>
{
await using var tx = await _db.Database
.BeginTransactionAsync(ct);
try
{
var result = await next();
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return result;
}
catch
{
await tx.RollbackAsync(ct);
throw;
}
});
}
}[Injectable(Lifetime.Scoped)]
public sealed class TransactionBehavior<TRequest, TResult>
: IBehavior<TRequest, TResult>
where TRequest : ICommand<TResult>
{
private readonly AppDbContext _db;
private readonly ILogger<TransactionBehavior<TRequest, TResult>> _logger;
public TransactionBehavior(AppDbContext db,
ILogger<TransactionBehavior<TRequest, TResult>> logger)
{ _db = db; _logger = logger; }
public async Task<TResult> HandleAsync(
TRequest request, Func<Task<TResult>> next,
CancellationToken ct = default)
{
// Skip if already in a transaction (e.g., saga step)
if (_db.Database.CurrentTransaction is not null)
return await next();
return await _db.Database.CreateExecutionStrategy()
.ExecuteAsync(async () =>
{
await using var tx = await _db.Database
.BeginTransactionAsync(ct);
try
{
var result = await next();
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return result;
}
catch
{
await tx.RollbackAsync(ct);
throw;
}
});
}
}The behavior checks for an existing transaction to avoid nesting -- important when the mediator is called from within a saga step.
Pipeline Ordering
Behaviors execute in the order they are registered in the DI container. The typical ordering is:
- LoggingBehavior (outermost -- logs everything, including validation and authorization failures)
- ValidationBehavior (short-circuits invalid requests before they reach authorization)
- AuthorizationBehavior (short-circuits forbidden requests before they reach the transaction)
- TransactionBehavior (innermost wrapper -- only opens a transaction for valid, authorized commands)
- Handler (center of the pipeline)
This ordering is not enforced by the type system. It is a registration convention. If you register behaviors in the wrong order -- say, transaction before validation -- you will open a database connection for requests that will be rejected by validation. The code will work, but it will waste resources. The ordering is documented, and the source-generated registration method emits behaviors in the declared order.
The sequence diagram shows the happy path: the controller sends a command, and it flows through logging, validation, authorization, and transaction, reaches the handler, and then the result bubbles back up through each layer. Each behavior can inspect and modify the result on the way back.
Writing Custom Behaviors
Beyond the four standard behaviors, you can write application-specific behaviors for any cross-cutting concern. The pattern is always the same: implement IBehavior<TRequest, TResult>, do work before and/or after calling next(), and optionally short-circuit by returning without calling next().
Performance monitoring:
[Injectable(Lifetime.Singleton)]
public sealed class PerformanceBehavior<TRequest, TResult>
: IBehavior<TRequest, TResult>
where TRequest : IRequest<TResult>
{
private readonly ILogger<PerformanceBehavior<TRequest, TResult>> _logger;
private readonly IClock _clock;
public PerformanceBehavior(
ILogger<PerformanceBehavior<TRequest, TResult>> logger,
IClock clock)
{
_logger = logger;
_clock = clock;
}
public async Task<TResult> HandleAsync(
TRequest request, Func<Task<TResult>> next,
CancellationToken ct = default)
{
var start = _clock.UtcNow;
var result = await next();
var elapsed = _clock.UtcNow - start;
if (elapsed.TotalMilliseconds > 500)
_logger.LogWarning("Slow: {Request} took {Ms}ms",
typeof(TRequest).Name, elapsed.TotalMilliseconds);
return result;
}
}[Injectable(Lifetime.Singleton)]
public sealed class PerformanceBehavior<TRequest, TResult>
: IBehavior<TRequest, TResult>
where TRequest : IRequest<TResult>
{
private readonly ILogger<PerformanceBehavior<TRequest, TResult>> _logger;
private readonly IClock _clock;
public PerformanceBehavior(
ILogger<PerformanceBehavior<TRequest, TResult>> logger,
IClock clock)
{
_logger = logger;
_clock = clock;
}
public async Task<TResult> HandleAsync(
TRequest request, Func<Task<TResult>> next,
CancellationToken ct = default)
{
var start = _clock.UtcNow;
var result = await next();
var elapsed = _clock.UtcNow - start;
if (elapsed.TotalMilliseconds > 500)
_logger.LogWarning("Slow: {Request} took {Ms}ms",
typeof(TRequest).Name, elapsed.TotalMilliseconds);
return result;
}
}Other common custom behaviors include retry (catching DbUpdateConcurrencyException and retrying N times), idempotency (checking an IIdempotencyStore before calling next()), and caching (returning cached results for query types). Each is a single class with a single method. Each composes with the others through next(). None know about each other. They are pure middleware.
Notifications and Publish Strategies
The second half of IMediator is the notification system. While SendAsync dispatches a request to one handler, PublishAsync dispatches a notification to zero or more handlers.
INotification and INotificationHandler
The interfaces are minimal:
public interface INotification { }
public interface INotificationHandler<in TNotification>
where TNotification : INotification
{
Task HandleAsync(
TNotification notification,
CancellationToken ct = default);
}public interface INotification { }
public interface INotificationHandler<in TNotification>
where TNotification : INotification
{
Task HandleAsync(
TNotification notification,
CancellationToken ct = default);
}INotification is a pure marker interface with no members. INotificationHandler<T> has a single method that takes the notification and returns Task. There is no result type -- notifications are fire-and-observe, not request-response.
A notification represents something that has already happened. An order was created. A payment was received. A user signed up. A threshold was exceeded. The notification does not ask for anything. It announces a fact. The handlers decide what to do with that fact.
public sealed record OrderCreatedNotification(
OrderId OrderId,
CustomerId CustomerId,
decimal Total,
DateTimeOffset CreatedAt) : INotification;
public sealed record PaymentReceivedNotification(
PaymentId PaymentId,
OrderId OrderId,
decimal Amount,
string Currency) : INotification;
public sealed record UserSignedUpNotification(
UserId UserId,
string Email,
DateTimeOffset SignedUpAt) : INotification;public sealed record OrderCreatedNotification(
OrderId OrderId,
CustomerId CustomerId,
decimal Total,
DateTimeOffset CreatedAt) : INotification;
public sealed record PaymentReceivedNotification(
PaymentId PaymentId,
OrderId OrderId,
decimal Amount,
string Currency) : INotification;
public sealed record UserSignedUpNotification(
UserId UserId,
string Email,
DateTimeOffset SignedUpAt) : INotification;Multiple handlers can subscribe to the same notification type:
[Injectable(Lifetime.Scoped)]
public sealed class SendOrderConfirmationEmail
: INotificationHandler<OrderCreatedNotification>
{
private readonly IEmailService _email;
public SendOrderConfirmationEmail(IEmailService email)
=> _email = email;
public async Task HandleAsync(
OrderCreatedNotification notification,
CancellationToken ct = default)
{
await _email.SendOrderConfirmationAsync(
notification.OrderId, notification.CustomerId, ct);
}
}
[Injectable(Lifetime.Scoped)]
public sealed class TrackOrderAnalytics
: INotificationHandler<OrderCreatedNotification>
{
private readonly IAnalyticsService _analytics;
public TrackOrderAnalytics(IAnalyticsService analytics)
=> _analytics = analytics;
public async Task HandleAsync(
OrderCreatedNotification notification,
CancellationToken ct = default)
{
await _analytics.TrackAsync(
"order_created",
new
{
notification.OrderId,
notification.CustomerId,
notification.Total,
notification.CreatedAt
},
ct);
}
}
[Injectable(Lifetime.Scoped)]
public sealed class WriteOrderAuditLog
: INotificationHandler<OrderCreatedNotification>
{
private readonly IAuditLog _audit;
public WriteOrderAuditLog(IAuditLog audit)
=> _audit = audit;
public async Task HandleAsync(
OrderCreatedNotification notification,
CancellationToken ct = default)
{
await _audit.WriteAsync(
new AuditEntry(
Entity: "Order",
EntityId: notification.OrderId.ToString(),
Action: "Created",
Timestamp: notification.CreatedAt,
Details: $"Order total: {notification.Total:C}"),
ct);
}
}[Injectable(Lifetime.Scoped)]
public sealed class SendOrderConfirmationEmail
: INotificationHandler<OrderCreatedNotification>
{
private readonly IEmailService _email;
public SendOrderConfirmationEmail(IEmailService email)
=> _email = email;
public async Task HandleAsync(
OrderCreatedNotification notification,
CancellationToken ct = default)
{
await _email.SendOrderConfirmationAsync(
notification.OrderId, notification.CustomerId, ct);
}
}
[Injectable(Lifetime.Scoped)]
public sealed class TrackOrderAnalytics
: INotificationHandler<OrderCreatedNotification>
{
private readonly IAnalyticsService _analytics;
public TrackOrderAnalytics(IAnalyticsService analytics)
=> _analytics = analytics;
public async Task HandleAsync(
OrderCreatedNotification notification,
CancellationToken ct = default)
{
await _analytics.TrackAsync(
"order_created",
new
{
notification.OrderId,
notification.CustomerId,
notification.Total,
notification.CreatedAt
},
ct);
}
}
[Injectable(Lifetime.Scoped)]
public sealed class WriteOrderAuditLog
: INotificationHandler<OrderCreatedNotification>
{
private readonly IAuditLog _audit;
public WriteOrderAuditLog(IAuditLog audit)
=> _audit = audit;
public async Task HandleAsync(
OrderCreatedNotification notification,
CancellationToken ct = default)
{
await _audit.WriteAsync(
new AuditEntry(
Entity: "Order",
EntityId: notification.OrderId.ToString(),
Action: "Created",
Timestamp: notification.CreatedAt,
Details: $"Order total: {notification.Total:C}"),
ct);
}
}Three handlers, each with a single responsibility: email, analytics, audit. They do not know about each other. They do not share state. They are registered independently in the DI container.
The Three Publish Strategies
When PublishAsync is called, the mediator resolves all INotificationHandler<T> instances from the container and invokes them according to the specified strategy:
public enum PublishStrategy
{
Sequential = 0,
Parallel = 1,
FireAndForget = 2
}public enum PublishStrategy
{
Sequential = 0,
Parallel = 1,
FireAndForget = 2
}Sequential (default)
Handlers are invoked one at a time, in registration order. If any handler throws, the remaining handlers are not invoked. Use when ordering matters or you want fail-fast behavior.
Parallel
All handlers are invoked concurrently via Task.WhenAll. Use when handlers are independent and you want to minimize total latency (three 200ms handlers complete in ~200ms instead of ~600ms).
FireAndForget
Handlers are invoked concurrently, and the mediator returns immediately without awaiting completion. Exceptions are swallowed. Use only for truly non-critical side effects (analytics, metrics, cache warming) where occasional silent failures are acceptable.
Notifications vs Domain Events
Notifications and domain events are related but distinct concepts. A domain event (OrderPlaced) is a fact from the domain model. A notification is a dispatch mechanism -- the envelope that carries the event to subscribers. You can implement domain events directly as INotification, or keep them as plain records and wrap them in a notification at the boundary. The first approach is simpler; the second keeps the domain layer free of infrastructure concerns. Both work.
Real-World Example: Order Processing
Let us put it all together with a complete example. An e-commerce system needs to process order creation: validate the command, authorize the user, create the order in a transaction, and notify subscribers (email, analytics, audit).
The Command
public sealed record CreateOrderCommand(
string CustomerId,
IReadOnlyList<CreateOrderLineItem> Items,
Address ShippingAddress) : ICommand<Result<OrderCreatedResponse>>;
public sealed record CreateOrderLineItem(
string Sku,
int Quantity);
public sealed record OrderCreatedResponse(
Guid OrderId,
decimal Total,
string Status);public sealed record CreateOrderCommand(
string CustomerId,
IReadOnlyList<CreateOrderLineItem> Items,
Address ShippingAddress) : ICommand<Result<OrderCreatedResponse>>;
public sealed record CreateOrderLineItem(
string Sku,
int Quantity);
public sealed record OrderCreatedResponse(
Guid OrderId,
decimal Total,
string Status);The Validator and Authorization Rule
The validator checks that the command has a customer ID, at least one line item with valid SKU and positive quantity, and a shipping address. The authorization rule allows admins to create orders for any customer, and regular users only for themselves. Both are registered with [Injectable(Lifetime.Scoped)] and discovered automatically by the ValidationBehavior and AuthorizationBehavior in the pipeline.
The Handler
[Injectable(Lifetime.Scoped)]
public sealed class CreateOrderHandler
: IRequestHandler<CreateOrderCommand, Result<OrderCreatedResponse>>
{
private readonly IOrderRepository _orders;
private readonly ICustomerRepository _customers;
private readonly IProductCatalog _catalog;
private readonly IClock _clock;
private readonly IMediator _mediator;
public CreateOrderHandler(
IOrderRepository orders,
ICustomerRepository customers,
IProductCatalog catalog,
IClock clock,
IMediator mediator)
{
_orders = orders;
_customers = customers;
_catalog = catalog;
_clock = clock;
_mediator = mediator;
}
public async Task<Result<OrderCreatedResponse>> HandleAsync(
CreateOrderCommand command,
CancellationToken ct = default)
{
var customer = await _customers.GetByIdAsync(
command.CustomerId, ct);
if (customer is null)
return Result.Failure<OrderCreatedResponse>(
Error.NotFound(
$"Customer {command.CustomerId} not found."));
// Resolve products and calculate prices
var lines = new List<OrderLine>();
foreach (var item in command.Items)
{
var product = await _catalog.GetBySkuAsync(item.Sku, ct);
if (product is null)
return Result.Failure<OrderCreatedResponse>(
Error.NotFound($"Product {item.Sku} not found."));
lines.Add(new OrderLine(
product.Sku,
item.Quantity,
product.UnitPrice));
}
var order = Order.Create(
customer, lines, command.ShippingAddress, _clock.UtcNow);
await _orders.AddAsync(order, ct);
// Publish notification for side effects
await _mediator.PublishAsync(
new OrderCreatedNotification(
order.Id, customer.Id, order.Total, _clock.UtcNow),
ct,
PublishStrategy.Parallel);
return Result.Success(new OrderCreatedResponse(
order.Id.Value,
order.Total,
order.Status.ToString()));
}
}[Injectable(Lifetime.Scoped)]
public sealed class CreateOrderHandler
: IRequestHandler<CreateOrderCommand, Result<OrderCreatedResponse>>
{
private readonly IOrderRepository _orders;
private readonly ICustomerRepository _customers;
private readonly IProductCatalog _catalog;
private readonly IClock _clock;
private readonly IMediator _mediator;
public CreateOrderHandler(
IOrderRepository orders,
ICustomerRepository customers,
IProductCatalog catalog,
IClock clock,
IMediator mediator)
{
_orders = orders;
_customers = customers;
_catalog = catalog;
_clock = clock;
_mediator = mediator;
}
public async Task<Result<OrderCreatedResponse>> HandleAsync(
CreateOrderCommand command,
CancellationToken ct = default)
{
var customer = await _customers.GetByIdAsync(
command.CustomerId, ct);
if (customer is null)
return Result.Failure<OrderCreatedResponse>(
Error.NotFound(
$"Customer {command.CustomerId} not found."));
// Resolve products and calculate prices
var lines = new List<OrderLine>();
foreach (var item in command.Items)
{
var product = await _catalog.GetBySkuAsync(item.Sku, ct);
if (product is null)
return Result.Failure<OrderCreatedResponse>(
Error.NotFound($"Product {item.Sku} not found."));
lines.Add(new OrderLine(
product.Sku,
item.Quantity,
product.UnitPrice));
}
var order = Order.Create(
customer, lines, command.ShippingAddress, _clock.UtcNow);
await _orders.AddAsync(order, ct);
// Publish notification for side effects
await _mediator.PublishAsync(
new OrderCreatedNotification(
order.Id, customer.Id, order.Total, _clock.UtcNow),
ct,
PublishStrategy.Parallel);
return Result.Success(new OrderCreatedResponse(
order.Id.Value,
order.Total,
order.Status.ToString()));
}
}The Notification
public sealed record OrderCreatedNotification(
OrderId OrderId,
CustomerId CustomerId,
decimal Total,
DateTimeOffset CreatedAt) : INotification;public sealed record OrderCreatedNotification(
OrderId OrderId,
CustomerId CustomerId,
decimal Total,
DateTimeOffset CreatedAt) : INotification;The Notification Handlers
Three independent handlers subscribe to the same notification. Each has a single responsibility and a single dependency:
[Injectable(Lifetime.Scoped)]
public sealed class SendOrderConfirmationOnOrderCreated
: INotificationHandler<OrderCreatedNotification>
{
private readonly IEmailService _email;
private readonly ICustomerRepository _customers;
public SendOrderConfirmationOnOrderCreated(
IEmailService email, ICustomerRepository customers)
{ _email = email; _customers = customers; }
public async Task HandleAsync(
OrderCreatedNotification n, CancellationToken ct = default)
{
var customer = await _customers.GetByIdAsync(n.CustomerId, ct);
if (customer is not null)
await _email.SendAsync(customer.Email,
$"Order {n.OrderId} Confirmed",
$"Thank you for your order of {n.Total:C}.", ct);
}
}
[Injectable(Lifetime.Scoped)]
public sealed class TrackOrderMetricsOnOrderCreated
: INotificationHandler<OrderCreatedNotification>
{
private readonly IMetricsCollector _metrics;
public TrackOrderMetricsOnOrderCreated(IMetricsCollector m) => _metrics = m;
public Task HandleAsync(
OrderCreatedNotification n, CancellationToken ct = default)
{
_metrics.IncrementCounter("orders_created_total");
_metrics.RecordHistogram("order_total_amount", n.Total);
return Task.CompletedTask;
}
}
[Injectable(Lifetime.Scoped)]
public sealed class WriteAuditLogOnOrderCreated
: INotificationHandler<OrderCreatedNotification>
{
private readonly IAuditLog _audit;
public WriteAuditLogOnOrderCreated(IAuditLog a) => _audit = a;
public async Task HandleAsync(
OrderCreatedNotification n, CancellationToken ct = default)
{
await _audit.WriteAsync(new AuditEntry(
"Order", n.OrderId.ToString(), "Created",
n.CreatedAt, $"Total: {n.Total:C}"), ct);
}
}[Injectable(Lifetime.Scoped)]
public sealed class SendOrderConfirmationOnOrderCreated
: INotificationHandler<OrderCreatedNotification>
{
private readonly IEmailService _email;
private readonly ICustomerRepository _customers;
public SendOrderConfirmationOnOrderCreated(
IEmailService email, ICustomerRepository customers)
{ _email = email; _customers = customers; }
public async Task HandleAsync(
OrderCreatedNotification n, CancellationToken ct = default)
{
var customer = await _customers.GetByIdAsync(n.CustomerId, ct);
if (customer is not null)
await _email.SendAsync(customer.Email,
$"Order {n.OrderId} Confirmed",
$"Thank you for your order of {n.Total:C}.", ct);
}
}
[Injectable(Lifetime.Scoped)]
public sealed class TrackOrderMetricsOnOrderCreated
: INotificationHandler<OrderCreatedNotification>
{
private readonly IMetricsCollector _metrics;
public TrackOrderMetricsOnOrderCreated(IMetricsCollector m) => _metrics = m;
public Task HandleAsync(
OrderCreatedNotification n, CancellationToken ct = default)
{
_metrics.IncrementCounter("orders_created_total");
_metrics.RecordHistogram("order_total_amount", n.Total);
return Task.CompletedTask;
}
}
[Injectable(Lifetime.Scoped)]
public sealed class WriteAuditLogOnOrderCreated
: INotificationHandler<OrderCreatedNotification>
{
private readonly IAuditLog _audit;
public WriteAuditLogOnOrderCreated(IAuditLog a) => _audit = a;
public async Task HandleAsync(
OrderCreatedNotification n, CancellationToken ct = default)
{
await _audit.WriteAsync(new AuditEntry(
"Order", n.OrderId.ToString(), "Created",
n.CreatedAt, $"Total: {n.Total:C}"), ct);
}
}The Controller
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
private readonly IMediator _mediator;
public OrdersController(IMediator mediator)
=> _mediator = mediator;
[HttpPost]
public async Task<IActionResult> Create(
[FromBody] CreateOrderRequest request,
CancellationToken ct)
{
var result = await _mediator.SendAsync(
new CreateOrderCommand(
request.CustomerId,
request.Items.Select(i =>
new CreateOrderLineItem(i.Sku, i.Quantity)).ToList(),
request.ShippingAddress),
ct);
return result.Match(
success: response => CreatedAtAction(
nameof(GetById),
new { id = response.OrderId },
response),
failure: error => error.ToProblemDetails());
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(
Guid id, CancellationToken ct)
{
var result = await _mediator.SendAsync(
new GetOrderByIdQuery(new OrderId(id)), ct);
return result.Match(
success: dto => Ok(dto),
failure: error => error.ToProblemDetails());
}
}[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
private readonly IMediator _mediator;
public OrdersController(IMediator mediator)
=> _mediator = mediator;
[HttpPost]
public async Task<IActionResult> Create(
[FromBody] CreateOrderRequest request,
CancellationToken ct)
{
var result = await _mediator.SendAsync(
new CreateOrderCommand(
request.CustomerId,
request.Items.Select(i =>
new CreateOrderLineItem(i.Sku, i.Quantity)).ToList(),
request.ShippingAddress),
ct);
return result.Match(
success: response => CreatedAtAction(
nameof(GetById),
new { id = response.OrderId },
response),
failure: error => error.ToProblemDetails());
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(
Guid id, CancellationToken ct)
{
var result = await _mediator.SendAsync(
new GetOrderByIdQuery(new OrderId(id)), ct);
return result.Match(
success: dto => Ok(dto),
failure: error => error.ToProblemDetails());
}
}The controller is 35 lines. One constructor parameter. Two action methods, each with three lines of logic. The controller translates between HTTP and domain commands/queries. Everything else -- validation, authorization, transaction management, business logic, notifications -- lives in dedicated classes that compose through the mediator pipeline.
Testing with FakeMediator
The FrenchExDev.Net.Mediator.Testing package provides FakeMediator, a test double that replaces the real mediator in unit tests. Unlike mocking IMediator with a mocking framework, FakeMediator provides a purpose-built API for the most common test scenarios: setting up canned responses, inspecting sent requests, and verifying published notifications.
The FakeMediator API
public sealed class FakeMediator : IMediator
{
// Inspection: what was sent and published
public List<object> SentRequests { get; } = new();
public List<INotification> PublishedNotifications { get; } = new();
// Setup: configure canned responses
public void Setup<TRequest, TResult>(
Func<TRequest, TResult> handler)
where TRequest : IRequest<TResult>;
// Dispatch: implements IMediator
public Task<TResult> SendAsync<TResult>(
IRequest<TResult> request,
CancellationToken ct = default);
public Task PublishAsync(
INotification notification,
CancellationToken ct = default,
PublishStrategy strategy = PublishStrategy.Sequential);
// Assertions: check what happened
public bool WasSent<TRequest>();
public bool WasPublished<TNotification>()
where TNotification : INotification;
// Reset: clear all state for the next test
public void Reset();
}public sealed class FakeMediator : IMediator
{
// Inspection: what was sent and published
public List<object> SentRequests { get; } = new();
public List<INotification> PublishedNotifications { get; } = new();
// Setup: configure canned responses
public void Setup<TRequest, TResult>(
Func<TRequest, TResult> handler)
where TRequest : IRequest<TResult>;
// Dispatch: implements IMediator
public Task<TResult> SendAsync<TResult>(
IRequest<TResult> request,
CancellationToken ct = default);
public Task PublishAsync(
INotification notification,
CancellationToken ct = default,
PublishStrategy strategy = PublishStrategy.Sequential);
// Assertions: check what happened
public bool WasSent<TRequest>();
public bool WasPublished<TNotification>()
where TNotification : INotification;
// Reset: clear all state for the next test
public void Reset();
}Setting Up Canned Responses
The Setup method registers a function that will be called when SendAsync receives a matching request type. The function takes the request and returns the result:
var mediator = new FakeMediator();
// When CreateOrderCommand is sent, return a success response
mediator.Setup<CreateOrderCommand, Result<OrderCreatedResponse>>(
cmd => Result.Success(new OrderCreatedResponse(
OrderId: Guid.NewGuid(),
Total: 99.99m,
Status: "Pending")));
// When GetOrderByIdQuery is sent, return a not-found failure
mediator.Setup<GetOrderByIdQuery, Result<OrderDto>>(
query => Result.Failure<OrderDto>(
Error.NotFound($"Order {query.OrderId} not found.")));var mediator = new FakeMediator();
// When CreateOrderCommand is sent, return a success response
mediator.Setup<CreateOrderCommand, Result<OrderCreatedResponse>>(
cmd => Result.Success(new OrderCreatedResponse(
OrderId: Guid.NewGuid(),
Total: 99.99m,
Status: "Pending")));
// When GetOrderByIdQuery is sent, return a not-found failure
mediator.Setup<GetOrderByIdQuery, Result<OrderDto>>(
query => Result.Failure<OrderDto>(
Error.NotFound($"Order {query.OrderId} not found.")));The setup function receives the actual request instance, so you can return different results based on the request properties:
mediator.Setup<GetOrderByIdQuery, Result<OrderDto>>(query =>
{
if (query.OrderId == KnownOrderId)
return Result.Success(new OrderDto(/* ... */));
return Result.Failure<OrderDto>(
Error.NotFound($"Order {query.OrderId} not found."));
});mediator.Setup<GetOrderByIdQuery, Result<OrderDto>>(query =>
{
if (query.OrderId == KnownOrderId)
return Result.Success(new OrderDto(/* ... */));
return Result.Failure<OrderDto>(
Error.NotFound($"Order {query.OrderId} not found."));
});If SendAsync is called with a request type that has no setup, FakeMediator throws an InvalidOperationException. This is intentional: a test that sends an unexpected request is a test that needs to be fixed.
Inspecting Sent Requests
Every call to SendAsync records the request in the SentRequests list. You can inspect this list to verify that the system under test sent the correct requests with the correct properties:
// Act
await controller.Create(request, CancellationToken.None);
// Assert: the correct command was sent
var sentCommand = mediator.SentRequests
.OfType<CreateOrderCommand>()
.Single();
Assert.Equal("cust-123", sentCommand.CustomerId);
Assert.Equal(2, sentCommand.Items.Count);
Assert.Equal("SKU-001", sentCommand.Items[0].Sku);
Assert.Equal(3, sentCommand.Items[0].Quantity);// Act
await controller.Create(request, CancellationToken.None);
// Assert: the correct command was sent
var sentCommand = mediator.SentRequests
.OfType<CreateOrderCommand>()
.Single();
Assert.Equal("cust-123", sentCommand.CustomerId);
Assert.Equal(2, sentCommand.Items.Count);
Assert.Equal("SKU-001", sentCommand.Items[0].Sku);
Assert.Equal(3, sentCommand.Items[0].Quantity);Inspecting Published Notifications
Similarly, every call to PublishAsync records the notification in the PublishedNotifications list:
// Act
await handler.HandleAsync(command, CancellationToken.None);
// Assert: the notification was published
var notification = mediator.PublishedNotifications
.OfType<OrderCreatedNotification>()
.Single();
Assert.Equal(expectedOrderId, notification.OrderId);
Assert.Equal(expectedTotal, notification.Total);// Act
await handler.HandleAsync(command, CancellationToken.None);
// Assert: the notification was published
var notification = mediator.PublishedNotifications
.OfType<OrderCreatedNotification>()
.Single();
Assert.Equal(expectedOrderId, notification.OrderId);
Assert.Equal(expectedTotal, notification.Total);WasSent and WasPublished
For tests where you only care that a request was sent (not about its properties), use the boolean helpers:
Assert.True(mediator.WasSent<CreateOrderCommand>());
Assert.True(mediator.WasPublished<OrderCreatedNotification>());
Assert.False(mediator.WasSent<CancelOrderCommand>());
Assert.False(mediator.WasPublished<PaymentReceivedNotification>());Assert.True(mediator.WasSent<CreateOrderCommand>());
Assert.True(mediator.WasPublished<OrderCreatedNotification>());
Assert.False(mediator.WasSent<CancelOrderCommand>());
Assert.False(mediator.WasPublished<PaymentReceivedNotification>());Reset
The Reset method clears all state: sent requests, published notifications, and registered setups. Use it in test setup or teardown when reusing a FakeMediator across multiple tests in the same class:
public class OrderControllerTests
{
private readonly FakeMediator _mediator = new();
private readonly OrdersController _controller;
public OrderControllerTests()
{
_controller = new OrdersController(_mediator);
}
// Tests can call _mediator.Reset() in a setup method
// if they need a clean slate, but typically each test
// sets up its own expectations.
}public class OrderControllerTests
{
private readonly FakeMediator _mediator = new();
private readonly OrdersController _controller;
public OrderControllerTests()
{
_controller = new OrdersController(_mediator);
}
// Tests can call _mediator.Reset() in a setup method
// if they need a clean slate, but typically each test
// sets up its own expectations.
}Complete Test Examples
Here are four test methods that demonstrate the core FakeMediator usage patterns -- setup, inspection, boolean assertions, and handler-level notification verification:
public class OrdersControllerTests
{
private readonly FakeMediator _mediator = new();
private readonly OrdersController _controller;
public OrdersControllerTests()
{
_controller = new OrdersController(_mediator);
}
[Fact]
public async Task Create_returns_201_on_success()
{
var expectedId = Guid.NewGuid();
_mediator.Setup<CreateOrderCommand, Result<OrderCreatedResponse>>(
cmd => Result.Success(new OrderCreatedResponse(
expectedId, 149.99m, "Pending")));
var request = new CreateOrderRequest("cust-1",
new[] { new OrderItemRequest("SKU-A", 2) },
TestAddresses.Default);
var actionResult = await _controller.Create(
request, CancellationToken.None);
var created = Assert.IsType<CreatedAtActionResult>(actionResult);
var response = Assert.IsType<OrderCreatedResponse>(created.Value);
Assert.Equal(expectedId, response.OrderId);
}
[Fact]
public async Task Create_sends_correct_command()
{
_mediator.Setup<CreateOrderCommand, Result<OrderCreatedResponse>>(
cmd => Result.Success(new OrderCreatedResponse(
Guid.NewGuid(), 0m, "Pending")));
await _controller.Create(new CreateOrderRequest("cust-42",
new[] { new OrderItemRequest("SKU-X", 1) },
TestAddresses.Paris), CancellationToken.None);
var command = _mediator.SentRequests
.OfType<CreateOrderCommand>().Single();
Assert.Equal("cust-42", command.CustomerId);
Assert.Equal("SKU-X", command.Items[0].Sku);
}
[Fact]
public async Task Create_returns_problem_details_on_failure()
{
_mediator.Setup<CreateOrderCommand, Result<OrderCreatedResponse>>(
cmd => Result.Failure<OrderCreatedResponse>(
Error.Validation("Invalid order.")));
var actionResult = await _controller.Create(
new CreateOrderRequest("", Array.Empty<OrderItemRequest>(),
null!),
CancellationToken.None);
var badRequest = Assert.IsType<ObjectResult>(actionResult);
Assert.Equal(400, badRequest.StatusCode);
}
[Fact]
public async Task Handler_publishes_notification_after_order_created()
{
var catalog = new FakeProductCatalog();
catalog.Add(new Product("SKU-A", 29.99m));
var customers = new FakeCustomerRepository();
customers.Add(new Customer(new CustomerId("cust-1"),
"user-1", "Alice", "alice@example.com"));
var clock = new FakeClock(
new DateTimeOffset(2026, 4, 3, 10, 0, 0, TimeSpan.Zero));
var handler = new CreateOrderHandler(
new FakeOrderRepository(), customers, catalog,
clock, _mediator);
var result = await handler.HandleAsync(
new CreateOrderCommand("cust-1",
new[] { new CreateOrderLineItem("SKU-A", 2) },
TestAddresses.Default),
CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.True(_mediator.WasPublished<OrderCreatedNotification>());
var notification = _mediator.PublishedNotifications
.OfType<OrderCreatedNotification>().Single();
Assert.Equal(59.98m, notification.Total);
}
}public class OrdersControllerTests
{
private readonly FakeMediator _mediator = new();
private readonly OrdersController _controller;
public OrdersControllerTests()
{
_controller = new OrdersController(_mediator);
}
[Fact]
public async Task Create_returns_201_on_success()
{
var expectedId = Guid.NewGuid();
_mediator.Setup<CreateOrderCommand, Result<OrderCreatedResponse>>(
cmd => Result.Success(new OrderCreatedResponse(
expectedId, 149.99m, "Pending")));
var request = new CreateOrderRequest("cust-1",
new[] { new OrderItemRequest("SKU-A", 2) },
TestAddresses.Default);
var actionResult = await _controller.Create(
request, CancellationToken.None);
var created = Assert.IsType<CreatedAtActionResult>(actionResult);
var response = Assert.IsType<OrderCreatedResponse>(created.Value);
Assert.Equal(expectedId, response.OrderId);
}
[Fact]
public async Task Create_sends_correct_command()
{
_mediator.Setup<CreateOrderCommand, Result<OrderCreatedResponse>>(
cmd => Result.Success(new OrderCreatedResponse(
Guid.NewGuid(), 0m, "Pending")));
await _controller.Create(new CreateOrderRequest("cust-42",
new[] { new OrderItemRequest("SKU-X", 1) },
TestAddresses.Paris), CancellationToken.None);
var command = _mediator.SentRequests
.OfType<CreateOrderCommand>().Single();
Assert.Equal("cust-42", command.CustomerId);
Assert.Equal("SKU-X", command.Items[0].Sku);
}
[Fact]
public async Task Create_returns_problem_details_on_failure()
{
_mediator.Setup<CreateOrderCommand, Result<OrderCreatedResponse>>(
cmd => Result.Failure<OrderCreatedResponse>(
Error.Validation("Invalid order.")));
var actionResult = await _controller.Create(
new CreateOrderRequest("", Array.Empty<OrderItemRequest>(),
null!),
CancellationToken.None);
var badRequest = Assert.IsType<ObjectResult>(actionResult);
Assert.Equal(400, badRequest.StatusCode);
}
[Fact]
public async Task Handler_publishes_notification_after_order_created()
{
var catalog = new FakeProductCatalog();
catalog.Add(new Product("SKU-A", 29.99m));
var customers = new FakeCustomerRepository();
customers.Add(new Customer(new CustomerId("cust-1"),
"user-1", "Alice", "alice@example.com"));
var clock = new FakeClock(
new DateTimeOffset(2026, 4, 3, 10, 0, 0, TimeSpan.Zero));
var handler = new CreateOrderHandler(
new FakeOrderRepository(), customers, catalog,
clock, _mediator);
var result = await handler.HandleAsync(
new CreateOrderCommand("cust-1",
new[] { new CreateOrderLineItem("SKU-A", 2) },
TestAddresses.Default),
CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.True(_mediator.WasPublished<OrderCreatedNotification>());
var notification = _mediator.PublishedNotifications
.OfType<OrderCreatedNotification>().Single();
Assert.Equal(59.98m, notification.Total);
}
}Testing Behaviors in Isolation
Behaviors can be tested independently by providing a fake next delegate. No mediator, no DI container, no handler -- just the behavior, a request, and a lambda:
public class ValidationBehaviorTests
{
[Fact]
public async Task Short_circuits_when_validation_fails()
{
var behavior = new ValidationBehavior<
CreateOrderCommand, Result<OrderCreatedResponse>>(
new[] { new CreateOrderCommandValidator() });
var handlerCalled = false;
var result = await behavior.HandleAsync(
new CreateOrderCommand("", new List<CreateOrderLineItem>(),
null!),
next: () =>
{
handlerCalled = true;
return Task.FromResult(Result.Success(
new OrderCreatedResponse(Guid.NewGuid(), 0m, "Pending")));
});
Assert.False(handlerCalled, "Handler should not be called.");
Assert.True(result.IsFailure);
}
[Fact]
public async Task Calls_next_when_validation_passes()
{
var behavior = new ValidationBehavior<
CreateOrderCommand, Result<OrderCreatedResponse>>(
new[] { new CreateOrderCommandValidator() });
var expected = new OrderCreatedResponse(
Guid.NewGuid(), 29.99m, "Pending");
var result = await behavior.HandleAsync(
new CreateOrderCommand("cust-1",
new[] { new CreateOrderLineItem("SKU-A", 1) },
TestAddresses.Default),
next: () => Task.FromResult(Result.Success(expected)));
Assert.True(result.IsSuccess);
Assert.Equal(expected, result.Value);
}
}public class ValidationBehaviorTests
{
[Fact]
public async Task Short_circuits_when_validation_fails()
{
var behavior = new ValidationBehavior<
CreateOrderCommand, Result<OrderCreatedResponse>>(
new[] { new CreateOrderCommandValidator() });
var handlerCalled = false;
var result = await behavior.HandleAsync(
new CreateOrderCommand("", new List<CreateOrderLineItem>(),
null!),
next: () =>
{
handlerCalled = true;
return Task.FromResult(Result.Success(
new OrderCreatedResponse(Guid.NewGuid(), 0m, "Pending")));
});
Assert.False(handlerCalled, "Handler should not be called.");
Assert.True(result.IsFailure);
}
[Fact]
public async Task Calls_next_when_validation_passes()
{
var behavior = new ValidationBehavior<
CreateOrderCommand, Result<OrderCreatedResponse>>(
new[] { new CreateOrderCommandValidator() });
var expected = new OrderCreatedResponse(
Guid.NewGuid(), 29.99m, "Pending");
var result = await behavior.HandleAsync(
new CreateOrderCommand("cust-1",
new[] { new CreateOrderLineItem("SKU-A", 1) },
TestAddresses.Default),
next: () => Task.FromResult(Result.Success(expected)));
Assert.True(result.IsSuccess);
Assert.Equal(expected, result.Value);
}
}FakeMediator vs Mocking Frameworks
You could mock IMediator with Moq or NSubstitute, but FakeMediator has four advantages: (1) type safety -- Setup is generic over the concrete request type, while Moq's It.IsAny<IRequest<...>>() matches any request with the same result type; (2) inspection -- SentRequests gives you actual request instances, which is simpler than Moq's Verify/Capture; (3) no mocking dependency -- it ships in the .Testing package; (4) reset -- Reset() clears all state in one call.
Comparison: MediatR vs FrenchExDev.Mediator
MediatR by Jimmy Bogard is the de facto standard mediator implementation in .NET. It has been in production since 2016, powers thousands of applications, and has a mature ecosystem of extensions. FrenchExDev's mediator is not a replacement for MediatR -- it is an alternative with different design priorities.
| Dimension | MediatR | FrenchExDev.Mediator |
|---|---|---|
| Package size | ~50KB + MediatR.Extensions.Microsoft.DependencyInjection | Interfaces only (~5KB) |
| CQRS markers | IRequest<T> only; no ICommand<T> / IQuery<T> |
ICommand<T>, IQuery<T> marker interfaces |
| Pipeline middleware | IPipelineBehavior<TRequest, TResponse> |
IBehavior<TRequest, TResult> |
| Result integration | None (you bring your own) | Designed for Result<T> |
| Notification strategies | Sequential only (by default) | Sequential, Parallel, FireAndForget |
| DI registration | services.AddMediatR(cfg => ...) |
[Injectable] source-generated |
| Stream requests | IStreamRequest<T> + IAsyncEnumerable |
Not included (use IEventStream<T> from Reactive) |
| Testing | Mock IMediator or use custom fakes |
FakeMediator included in .Testing |
| Runtime dispatcher | Yes -- MediatR ships the dispatcher | No -- contracts only, wire your own |
| Open-source | Yes (Apache 2.0) | Yes |
The most significant differences are: FrenchExDev provides ICommand<T>/IQuery<T> markers out of the box (MediatR only has IRequest<T>), makes notification dispatch strategy explicit in the API (MediatR defaults to sequential with no built-in enum), ships no runtime dispatcher (contracts only -- you wire your own), and ships FakeMediator in the .Testing package (MediatR requires a mocking framework).
Contracts-Only Design
FrenchExDev.Net.Mediator is a contracts-only library. It defines the interfaces (IMediator, IRequest<T>, ICommand<T>, IQuery<T>, IRequestHandler<T, R>, IBehavior<T, R>, INotification, INotificationHandler<T>) and the PublishStrategy enum. It does not define a runtime implementation.
This is intentional. The library answers "what is the contract?" without answering "how are requests dispatched?" The "how" depends on your DI container, your resolution strategy (reflection, compiled delegates, source generation), and your pipeline ordering preference. By keeping these decisions out of the contracts library, FrenchExDev avoids lock-in (dispatcher changes are not breaking changes) and container coupling (MediatR couples to IServiceProvider; FrenchExDev's contracts have no opinion about the container).
The Typical Implementation
In practice, the dispatcher is a simple class that resolves handlers and behaviors from IServiceProvider:
[Injectable(Lifetime.Scoped)]
internal sealed class Mediator : IMediator
{
private readonly IServiceProvider _provider;
public Mediator(IServiceProvider provider) => _provider = provider;
public async Task<TResult> SendAsync<TResult>(
IRequest<TResult> request, CancellationToken ct = default)
{
var requestType = request.GetType();
var handler = _provider.GetRequiredService(
typeof(IRequestHandler<,>).MakeGenericType(
requestType, typeof(TResult)));
// Build behavior pipeline: wrap handler in each behavior
var behaviors = _provider
.GetServices(typeof(IBehavior<,>).MakeGenericType(
requestType, typeof(TResult)))
.Cast<dynamic>().Reverse().ToList();
Func<Task<TResult>> pipeline = () =>
(Task<TResult>)((dynamic)handler)
.HandleAsync((dynamic)request, ct);
foreach (var behavior in behaviors)
{
var next = pipeline;
pipeline = () => (Task<TResult>)behavior
.HandleAsync((dynamic)request, next, ct);
}
return await pipeline();
}
public async Task PublishAsync(
INotification notification, CancellationToken ct = default,
PublishStrategy strategy = PublishStrategy.Sequential)
{
var handlers = _provider
.GetServices(typeof(INotificationHandler<>)
.MakeGenericType(notification.GetType()))
.Cast<dynamic>().ToList();
switch (strategy)
{
case PublishStrategy.Sequential:
foreach (var h in handlers)
await h.HandleAsync((dynamic)notification, ct);
break;
case PublishStrategy.Parallel:
await Task.WhenAll(handlers.Select(h =>
(Task)h.HandleAsync((dynamic)notification, ct)));
break;
case PublishStrategy.FireAndForget:
foreach (var h in handlers)
_ = Task.Run(() =>
h.HandleAsync((dynamic)notification, ct), ct);
break;
}
}
}[Injectable(Lifetime.Scoped)]
internal sealed class Mediator : IMediator
{
private readonly IServiceProvider _provider;
public Mediator(IServiceProvider provider) => _provider = provider;
public async Task<TResult> SendAsync<TResult>(
IRequest<TResult> request, CancellationToken ct = default)
{
var requestType = request.GetType();
var handler = _provider.GetRequiredService(
typeof(IRequestHandler<,>).MakeGenericType(
requestType, typeof(TResult)));
// Build behavior pipeline: wrap handler in each behavior
var behaviors = _provider
.GetServices(typeof(IBehavior<,>).MakeGenericType(
requestType, typeof(TResult)))
.Cast<dynamic>().Reverse().ToList();
Func<Task<TResult>> pipeline = () =>
(Task<TResult>)((dynamic)handler)
.HandleAsync((dynamic)request, ct);
foreach (var behavior in behaviors)
{
var next = pipeline;
pipeline = () => (Task<TResult>)behavior
.HandleAsync((dynamic)request, next, ct);
}
return await pipeline();
}
public async Task PublishAsync(
INotification notification, CancellationToken ct = default,
PublishStrategy strategy = PublishStrategy.Sequential)
{
var handlers = _provider
.GetServices(typeof(INotificationHandler<>)
.MakeGenericType(notification.GetType()))
.Cast<dynamic>().ToList();
switch (strategy)
{
case PublishStrategy.Sequential:
foreach (var h in handlers)
await h.HandleAsync((dynamic)notification, ct);
break;
case PublishStrategy.Parallel:
await Task.WhenAll(handlers.Select(h =>
(Task)h.HandleAsync((dynamic)notification, ct)));
break;
case PublishStrategy.FireAndForget:
foreach (var h in handlers)
_ = Task.Run(() =>
h.HandleAsync((dynamic)notification, ct), ct);
break;
}
}
}This implementation uses dynamic dispatch, which incurs a small runtime cost on the first invocation (the DLR caches the call site afterward). A source-generated implementation can eliminate this cost entirely by emitting closed generic dispatch methods at compile time. Both approaches implement the same IMediator interface, and consumer code does not know or care which one is used.
The key point is that this class is not part of the library. It is part of your application. You own it, you can change it, you can optimize it. The contracts are stable. The implementation is yours.
DI Registration
Handlers and behaviors are registered with the [Injectable] attribute, just like every other FrenchExDev service:
[Injectable(Lifetime.Scoped)]
public sealed class CreateOrderHandler
: IRequestHandler<CreateOrderCommand, Result<OrderId>>
{
// ...
}
[Injectable(Lifetime.Singleton)]
public sealed class LoggingBehavior<TRequest, TResult>
: IBehavior<TRequest, TResult>
where TRequest : IRequest<TResult>
{
// ...
}
[Injectable(Lifetime.Scoped)]
public sealed class SendOrderConfirmationOnOrderCreated
: INotificationHandler<OrderCreatedNotification>
{
// ...
}[Injectable(Lifetime.Scoped)]
public sealed class CreateOrderHandler
: IRequestHandler<CreateOrderCommand, Result<OrderId>>
{
// ...
}
[Injectable(Lifetime.Singleton)]
public sealed class LoggingBehavior<TRequest, TResult>
: IBehavior<TRequest, TResult>
where TRequest : IRequest<TResult>
{
// ...
}
[Injectable(Lifetime.Scoped)]
public sealed class SendOrderConfirmationOnOrderCreated
: INotificationHandler<OrderCreatedNotification>
{
// ...
}The source generator scans the assembly for [Injectable] attributes, discovers the implemented interfaces, and emits explicit registration calls:
// Source-generated: Do not edit.
internal static class InjectableRegistration
{
public static IServiceCollection AddMyAppInjectables(
this IServiceCollection services)
{
// Handlers (closed generics)
services.AddScoped<
IRequestHandler<CreateOrderCommand, Result<OrderId>>,
CreateOrderHandler>();
services.AddScoped<
IRequestHandler<GetOrderByIdQuery, Result<OrderDto>>,
GetOrderByIdHandler>();
// Behaviors (open generics, in pipeline order)
services.AddSingleton(typeof(IBehavior<,>), typeof(LoggingBehavior<,>));
services.AddScoped(typeof(IBehavior<,>), typeof(ValidationBehavior<,>));
services.AddScoped(typeof(IBehavior<,>), typeof(AuthorizationBehavior<,>));
services.AddScoped(typeof(IBehavior<,>), typeof(TransactionBehavior<,>));
// Notification handlers
services.AddScoped<INotificationHandler<OrderCreatedNotification>,
SendOrderConfirmationOnOrderCreated>();
services.AddScoped<INotificationHandler<OrderCreatedNotification>,
TrackOrderMetricsOnOrderCreated>();
services.AddScoped<INotificationHandler<OrderCreatedNotification>,
WriteAuditLogOnOrderCreated>();
// Mediator implementation
services.AddScoped<IMediator, Mediator>();
return services;
}
}// Source-generated: Do not edit.
internal static class InjectableRegistration
{
public static IServiceCollection AddMyAppInjectables(
this IServiceCollection services)
{
// Handlers (closed generics)
services.AddScoped<
IRequestHandler<CreateOrderCommand, Result<OrderId>>,
CreateOrderHandler>();
services.AddScoped<
IRequestHandler<GetOrderByIdQuery, Result<OrderDto>>,
GetOrderByIdHandler>();
// Behaviors (open generics, in pipeline order)
services.AddSingleton(typeof(IBehavior<,>), typeof(LoggingBehavior<,>));
services.AddScoped(typeof(IBehavior<,>), typeof(ValidationBehavior<,>));
services.AddScoped(typeof(IBehavior<,>), typeof(AuthorizationBehavior<,>));
services.AddScoped(typeof(IBehavior<,>), typeof(TransactionBehavior<,>));
// Notification handlers
services.AddScoped<INotificationHandler<OrderCreatedNotification>,
SendOrderConfirmationOnOrderCreated>();
services.AddScoped<INotificationHandler<OrderCreatedNotification>,
TrackOrderMetricsOnOrderCreated>();
services.AddScoped<INotificationHandler<OrderCreatedNotification>,
WriteAuditLogOnOrderCreated>();
// Mediator implementation
services.AddScoped<IMediator, Mediator>();
return services;
}
}One method call in Program.cs: builder.Services.AddMyAppInjectables(). No services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(...)). No assembly scanning at runtime. The generated code is visible, inspectable, and debuggable.
Behavior Registration Order
The order of services.Add* calls determines the pipeline order. The source generator emits behaviors in the order they appear in source code (by file name, then by line number). Name your behavior files with numeric prefixes (01_LoggingBehavior.cs, 02_ValidationBehavior.cs) to make the pipeline order visible in the Solution Explorer.
When to Use the Mediator
The mediator is not a universal solution. It adds a layer of indirection, and indirection has a cost: more types (request, handler, behaviors), more files, more DI registrations, more mental overhead when navigating the codebase (you cannot "Go to Definition" from SendAsync to the handler -- you must search for the handler by request type).
Use the Mediator When
You have cross-cutting concerns that apply to many requests. The break-even point is roughly three to five behaviors applied to ten or more request types. Below that, the overhead exceeds the duplication it eliminates.
You want to enforce CQRS at the architectural level. The marker interfaces make the command/query split visible in the type system, and behaviors can target one side or the other.
You want thin controllers. The mediator slims fat controllers to a single SendAsync call. The controller becomes a translation layer between HTTP and domain commands.
You need to decouple sender from handler. In a modular monolith, module A sends a command that module B handles, without direct references.
You need configurable notification dispatch. Domain events triggering multiple side effects benefit from explicit control over sequential, parallel, or fire-and-forget execution.
Do NOT Use the Mediator When
Your application is simple CRUD without cross-cutting concerns. If the controller validates, calls a repository, and returns, adding a mediator creates three files where one method sufficed.
You have one or two endpoints. A microservice with two endpoints does not need a behavior pipeline. Write the logic inline. Add the mediator later if the service grows.
You are building a library. Libraries should not prescribe an architectural pattern. Depend on plain interfaces and let the consumer choose their dispatch mechanism.
You want "Go to Definition" to work. Pressing F12 on SendAsync(new CreateOrderCommand(...)) takes you to the IMediator interface, not to CreateOrderHandler. You must search for the handler manually. Some developers find this indirection annoying enough to avoid the mediator entirely. This is a legitimate tradeoff.
You are using the mediator as a service locator. If handlers send commands to other handlers, you are using the mediator as a service locator and losing the decoupling benefits. Handlers should depend on domain services or repositories, not on IMediator for orchestration (that is the saga's job).
Composition with Other FrenchExDev Patterns
The mediator integrates with every other pattern because they share the same vocabulary and DI conventions.
Result<T>. Handlers return Result<T>. Behaviors inspect it to short-circuit. Controllers pattern-match on it to choose HTTP status codes. The entire pipeline speaks Result<T>.
Guard and Option<T>. Validators use Guard.ToResult to accumulate errors. Handlers use Option<T> for optional lookups, converting to Result<T> at the boundary:
var order = await _orders.FindByIdAsync(query.OrderId, ct);
return order
.ToResult(() => Error.NotFound($"Order {query.OrderId} not found."))
.Map(o => o.ToDto());var order = await _orders.FindByIdAsync(query.OrderId, ct);
return order
.ToResult(() => Error.NotFound($"Order {query.OrderId} not found."))
.Map(o => o.ToDto());Clock. Behaviors and handlers inject IClock for timestamps. In tests, FakeClock makes elapsed-time assertions deterministic.
Mapper. Query handlers use IMapper<Order, OrderDto> for source-generated, zero-reflection projection.
Reactive. Notification handlers can publish to IEventStream<T> for downstream reactive processing -- feeding real-time dashboards or analytics streams.
Saga. A command handler can start a saga via ISagaOrchestrator<T>.ExecuteAsync, orchestrating multi-step workflows (reserve inventory, charge payment, ship) with automatic compensation on failure.
Outbox. The TransactionBehavior wraps the handler in a transaction. The OutboxInterceptor captures domain events within the same transaction. When it commits, both the aggregate and the outbox messages are persisted atomically.
Pitfall 1: Sending Commands from Inside Handlers
Publishing notifications from a handler is fine -- the handler created something and announces it. But sending commands from a handler is a smell. It means the handler is orchestrating multiple operations, which is the saga's job. If the inner command fails, should the outer handler roll back? The handler does not know. The saga does.
Pitfall 2: Behaviors That Swallow Exceptions
// BAD: exception is swallowed
catch (Exception ex)
{
_logger.LogError(ex, "Request failed.");
return default!; // Caller gets null/default with no indication of failure.
}// BAD: exception is swallowed
catch (Exception ex)
{
_logger.LogError(ex, "Request failed.");
return default!; // Caller gets null/default with no indication of failure.
}Either re-throw after logging, or return Result.Failure with error details. Never return default.
Pitfall 3: Mutating Request Objects in Behaviors
Request objects should be immutable (sealed records). If you need to enrich a request with a timestamp or correlation ID, pass the data through a scoped service, not by mutating the request via dynamic.
Pitfall 4: Too Many Behaviors
Four to six behaviors is the sweet spot. Ten behaviors means ten layers of middleware for every request. Most will just call next() immediately. The overhead is small per behavior but adds up. Profile if you suspect latency issues, and target behaviors at specific request types (ICommand<T> or IQuery<T>) instead of applying them globally.
Pitfall 5: Using Notifications for Synchronous Workflows
If you need a result from an operation, use SendAsync with a command. Notifications do not return results. Payment processing is not a side effect -- it is a critical operation whose outcome determines the next step. Use PublishAsync only for fire-and-observe side effects.
Summary
The Mediator pattern replaces fat controllers and scattered cross-cutting concerns with a single dispatch point and a composable pipeline. Here is the complete API surface:
| Type | Purpose | Cardinality |
|---|---|---|
IMediator |
Dispatch requests and publish notifications | 1 implementation |
IRequest<TResult> |
Base marker for all requests | N concrete types |
ICommand<TResult> |
Write-side CQRS marker | N concrete types |
IQuery<TResult> |
Read-side CQRS marker | N concrete types |
IRequestHandler<TRequest, TResult> |
Handles exactly one request type | 1 per request type |
IBehavior<TRequest, TResult> |
Pipeline middleware | M per pipeline |
INotification |
Marker for events | N concrete types |
INotificationHandler<TNotification> |
Handles notifications | 0..N per notification |
PublishStrategy |
Sequential, Parallel, FireAndForget | Enum (3 values) |
FakeMediator |
Test double | 1 (in .Testing) |
The key design decisions are:
- CQRS markers (
ICommand<T>,IQuery<T>) make the command/query split visible at the type level and enable behaviors to target one side or the other. - Behaviors compose cross-cutting concerns without touching handler code. Logging, validation, authorization, and transactions are each a single class.
- Publish strategies make notification dispatch explicit: sequential for ordered execution, parallel for throughput, fire-and-forget for non-critical side effects.
- Contracts only: the library ships interfaces, not a runtime dispatcher. You own the implementation.
- FakeMediator eliminates the need for mocking frameworks in mediator-dependent tests.
Eight interfaces. One enum. One test double. Zero hidden dispatch. Every request is a type. Every handler is a class. Every behavior is middleware. And the controller has one constructor parameter.
Next in the series: Part VIII: The Reactive Pattern, where we wrap System.Reactive with IEventStream<T>, add domain-oriented operators like Filter, Map, Merge, Buffer, and Throttle, provide an AsObservable() escape hatch for full Rx interop, and ship TestEventStream<T> for deterministic event testing.