Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

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());
    }
}

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());
    }
}

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);
}

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).

Diagram
The mediator as the only coupling point — ICommand and IQuery both specialise IRequest, behaviors wrap handlers, and notifications travel a parallel one-to-many path; no type references another directly.

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> { }

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> { }

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>;

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> { }

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>>;

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> { /* ... */ }

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."

Diagram
CQRS rendered through one mediator — the same SendAsync entry point, but marker interfaces split the write path (handler, aggregate, events) from the read path (query handler, view model).

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);
}

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);
    }
}

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));
    }
}

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);
}

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();
    }
}

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;
}

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();
}

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;
        }
    }
}

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.");
    }
}

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)}.");
    }
}

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;
            }
        });
    }
}

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:

  1. LoggingBehavior (outermost -- logs everything, including validation and authorization failures)
  2. ValidationBehavior (short-circuits invalid requests before they reach authorization)
  3. AuthorizationBehavior (short-circuits forbidden requests before they reach the transaction)
  4. TransactionBehavior (innermost wrapper -- only opens a transaction for valid, authorized commands)
  5. 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.

Diagram
The mediator pipeline as a Russian-doll call stack — logging wraps validation wraps authorization wraps transaction wraps handler — each layer free to inspect the result on the way back.

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;
    }
}

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);
}

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;

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);
    }
}

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
}

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.

Diagram
The three PublishAsync strategies — Sequential, Parallel, FireAndForget — each trading fail-fast guarantees for latency or detachment, picked per notification rather than per mediator.

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);

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()));
    }
}

The Notification

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);
    }
}

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());
    }
}

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();
}

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.")));

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."));
});

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);

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);

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>());

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.
}

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);
    }
}

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);
    }
}

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;
        }
    }
}

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>
{
    // ...
}

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;
    }
}

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());

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.
}

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:

  1. 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.
  2. Behaviors compose cross-cutting concerns without touching handler code. Logging, validation, authorization, and transactions are each a single class.
  3. Publish strategies make notification dispatch explicit: sequential for ordered execution, parallel for throughput, fire-and-forget for non-critical side effects.
  4. Contracts only: the library ships interfaces, not a runtime dispatcher. You own the implementation.
  5. 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.

⬇ Download