Building Your Own Mediator in .NET: From Pattern to Production

Published at Feb 19, 2026

#csharp#dotnet#mediator#design-patterns#architecture#dependency-injection

If you’ve worked with .NET long enough, you’ve probably heard of MediatR. It used to be the go-to package for implementing the Mediator pattern in C# applications. Then it went paid.

That was enough motivation for me to do two things: actually understand the pattern from the ground up — and build my own implementation.

This article walks through everything: what the Mediator pattern is, how it works internally, when it’s worth using, and how my open-source implementation (MyNetPackages.MyMediator) works under the hood.


1) What is the Mediator pattern?

The Mediator is a behavioral design pattern from the Gang of Four (GoF). Its core idea is simple: instead of objects communicating directly with each other, they communicate through a central intermediary — the mediator.

Without it, your components end up like this:

ComponentA ──── ComponentB
    │                │
    └──── ComponentC ┘

Every component needs to know about every other component. That’s tight coupling, and it gets worse as the system grows.

With a mediator:

ComponentA ──┐
ComponentB ──┼──► Mediator
ComponentC ──┘

Components only know about the mediator. The mediator knows how to route messages to the right handler.

In the context of .NET applications

In .NET, the Mediator pattern is usually applied at the application layer, often in CQRS (Command Query Responsibility Segregation) architectures:

  • A Command (e.g., CreateUserCommand) is sent through the mediator
  • The mediator finds and invokes the corresponding Handler
  • The handler returns a typed Response

This keeps your controllers, services, and app logic clean and decoupled.


2) How does it work?

The mechanics are straightforward once you see them:

The contracts

Everything starts with three small interfaces:

// Marks a message that expects a typed response
public interface IRequest<TResponse> { }

// Handles a specific request
public interface IRequestHandler<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken);
}

// The mediator itself
public interface IMediator
{
    Task<TResponse> Send<TResponse>(IRequest<TResponse> request, CancellationToken cancellationToken = default);
}

The pipeline

When you call mediator.Send(request), three things happen in order:

Pre-Processors → Handler → Post-Processors
  • Pre-Processors (IPreProcessor<TRequest>) — run before the handler. Useful for validation, logging, authorization checks.
  • Handler (IRequestHandler<TRequest, TResponse>) — the core logic, exactly one per request type.
  • Post-Processors (IPostProcessor<TRequest, TResponse>) — run after the handler with access to both the request and the response. Useful for audit logs, caching, side effects.

Multiple pre/post processors per request type are all invoked in registration order.

A concrete example

Define the request and response:

public class CreateUserResponse
{
    public bool Success { get; set; }
    public string Message { get; set; }
}

public class CreateUserCommand : IRequest<CreateUserResponse>
{
    public string Name { get; set; }
}

Implement the handler:

public class CreateUserHandler : IRequestHandler<CreateUserCommand, CreateUserResponse>
{
    public async Task<CreateUserResponse> Handle(CreateUserCommand request, CancellationToken cancellationToken)
    {
        var id = Guid.NewGuid();
        // Simulate persistence...
        return new CreateUserResponse
        {
            Success = true,
            Message = $"User '{request.Name}' created with ID {id}"
        };
    }
}

Add a pre-processor for validation:

public class CreateUserValidationPreProcessor : IPreProcessor<CreateUserCommand>
{
    public Task Process(CreateUserCommand request, CancellationToken cancellationToken)
    {
        if (string.IsNullOrWhiteSpace(request.Name))
            throw new ArgumentException("Name cannot be empty.");

        return Task.CompletedTask;
    }
}

Add a post-processor for audit logging:

public class CreateUserAuditPostProcessor : IPostProcessor<CreateUserCommand, CreateUserResponse>
{
    public Task Process(CreateUserCommand request, CreateUserResponse response, CancellationToken cancellationToken)
    {
        Console.WriteLine($"User '{request.Name}' created. Success: {response.Success}");
        return Task.CompletedTask;
    }
}

Use it in your application service:

public class UserAppService(IMediator mediator)
{
    public async Task<CreateUserResponse> CreateUser(string name)
    {
        return await mediator.Send(new CreateUserCommand { Name = name });
    }
}

The app service has no knowledge of the handler, the validators, or the audit logger. It only knows about IMediator and the request type.


3) Who should use it, and when?

The Mediator pattern is not for every project. Here’s an honest breakdown.

When it makes sense

  • CQRS architectures — separating commands (write) from queries (read) maps naturally to the IRequest<TResponse> model.
  • Growing application layers — when your controllers or services accumulate too many injected dependencies, the mediator flattens that coupling.
  • Cross-cutting concerns — validation, logging, caching, and authorization become pluggable pre/post processors instead of scattered across handlers.
  • Testability — handlers are small, focused, and trivial to unit test in isolation.

When it doesn’t

  • Small APIs or scripts — adding a mediator layer to a 3-endpoint service is over-engineering.
  • High-throughput hot paths — the pattern introduces indirection (interface resolution, reflection). For the vast majority of apps this is negligible, but it’s worth being aware of for extreme performance requirements.
  • Teams unfamiliar with the pattern — if the team doesn’t understand the pattern, the mediator becomes a black box that hides what’s actually happening.

The CQRS + Mediator sweet spot

The pattern truly shines in modular monoliths and vertical-slice architectures:

POST /users

UserController

IMediator.Send(CreateUserCommand)

[Validation] → [Handler] → [Audit]

Each slice (feature) owns its request, handler, and processors. No shared service classes growing out of control.


4) Why I built my own implementation

In early 2024, MediatR’s licensing changed. Starting from version 12, commercial use requires a paid license. For personal projects, open-source work, or teams that just can’t justify the cost, this creates friction.

That was my starting point — but it quickly became something more. I realized I’d been using MediatR for years without truly understanding what was happening inside mediator.Send(). Building from scratch forced me to understand every line.

What the implementation does

The core is around 50 lines of C#. The Mediator class receives an IServiceProvider via constructor injection and uses reflection to:

  1. Resolve all registered IPreProcessor<TRequest> and invoke them in order
  2. Resolve the single IRequestHandler<TRequest, TResponse> and invoke it
  3. Resolve all registered IPostProcessor<TRequest, TResponse> and invoke them in order
public class Mediator(IServiceProvider provider) : IMediator
{
    public async Task<TResponse> Send<TResponse>(IRequest<TResponse> request, CancellationToken cancellationToken = default)
    {
        var requestType = request.GetType();
        var responseType = typeof(TResponse);

        // Pre-processors
        var preProcessorType = typeof(IPreProcessor<>).MakeGenericType(requestType);
        var preProcessors = provider.GetServices(preProcessorType);
        foreach (var preProcessor in preProcessors)
        {
            if (preProcessor == null) continue;
            await (Task)preProcessorType
                .GetMethod("Process")!
                .Invoke(preProcessor, [request, cancellationToken])!;
        }

        // Handler — exactly one, throws if missing
        var handlerType = typeof(IRequestHandler<,>).MakeGenericType(requestType, responseType);
        var handler = provider.GetService(handlerType)
            ?? throw new InvalidOperationException($"Handler not found for {requestType.Name}");
        var response = (await (Task<TResponse>)handlerType
            .GetMethod("Handle")!
            .Invoke(handler, [request, cancellationToken])!)!;

        // Post-processors
        var postProcessorType = typeof(IPostProcessor<,>).MakeGenericType(requestType, responseType);
        var postProcessors = provider.GetServices(postProcessorType);
        foreach (var postProcessor in postProcessors)
        {
            if (postProcessor == null) continue;
            await (Task)postProcessorType
                .GetMethod("Process")!
                .Invoke(postProcessor, [request, response, cancellationToken])!;
        }

        return response;
    }
}

Assembly scanning with AddMyMediator()

Manual registration is available for full control:

services.AddSingleton<IMediator, Mediator>();
services.AddTransient<IRequestHandler<CreateUserCommand, CreateUserResponse>, CreateUserHandler>();
services.AddTransient<IPreProcessor<CreateUserCommand>, CreateUserValidationPreProcessor>();

But for most apps, one call is enough:

services.AddMyMediator();

This scans all loaded assemblies, finds every class implementing IRequestHandler<,>, IPreProcessor<>, or IPostProcessor<,>, and registers them automatically as transient services.

What’s under the hood of AddMyMediator()

public static IServiceCollection AddMyMediator(this IServiceCollection services, params object[] args)
{
    var assemblies = AppDomain.CurrentDomain
        .GetAssemblies()
        .Where(a => !a.IsDynamic && !string.IsNullOrWhiteSpace(a.FullName))
        .ToArray();

    services.AddSingleton<IMediator, Mediator>();

    RegisterHandlers(services, assemblies, typeof(IRequestHandler<,>));
    RegisterHandlers(services, assemblies, typeof(IPreProcessor<>));
    RegisterHandlers(services, assemblies, typeof(IPostProcessor<,>));

    return services;
}

Clean, predictable, and easy to follow.

Tested pipeline

The pipeline order is tested explicitly:

[Fact]
public async Task Pipeline_ShouldExecuteInOrder_PreProcessor_Then_Handler_Then_PostProcessor()
{
    var callLog = new List<string>();

    var services = new ServiceCollection();
    services.AddSingleton<IMediator, Mediator>();
    services.AddSingleton<IPreProcessor<FakeRequest>>(new FakePreProcessor(callLog));
    services.AddTransient<IRequestHandler<FakeRequest, string>>(_ => new FakeRequestHandler(callLog));
    services.AddSingleton<IPostProcessor<FakeRequest, string>>(new FakePostProcessor(callLog));

    var mediator = services.BuildServiceProvider().GetRequiredService<IMediator>();
    await mediator.Send(new FakeRequest { Name = "Frank" });

    Assert.Equal("PreProcessor", callLog[0]);
    Assert.Equal("Handler", callLog[1]);
    Assert.Equal("PostProcessor", callLog[2]);
}

What it doesn’t do (yet)

  • Notifications/events — broadcasting to multiple handlers with no response (like MediatR’s INotification)
  • Streaming responsesIAsyncEnumerable<TResponse> support
  • Cancellation-aware processor chains — processors don’t short-circuit the pipeline on cancellation today

These are conscious trade-offs to keep it simple. The goal was a working, understandable, dependency-free core — not a full MediatR clone.


5) Try it

The package is published on NuGet under the MIT license:

dotnet add package MyNetPackages.MyMediator

The source code and all tests are on GitHub. If you’re studying design patterns, want a lightweight alternative to MediatR for your project, or just want to read clean C# that implements something real — take a look.

If it’s useful, give it a star. If you find something missing or want to contribute a feature, open an issue or a PR.


Understanding what’s happening inside your abstractions makes you a better engineer. The Mediator pattern is one of those abstractions that looks like magic until you build it yourself — and then you realize it’s just 50 lines of reflection and a well-placed IServiceProvider.