
Replacing MediatR with a Custom Mediator (Dynamic Dispatch) in WEB API
Author - Abdul Rahman (Content Writer)
Web API
23 Articles
Table of Contents
What we gonna do?
Every .NET developer has been there: adding MediatR to a project. In this article, let's learn how to replace MediatR with a lightweight, custom mediator implementation that gives you complete control over your request dispatch pipeline in ASP.NET Web API.
Why we gonna do?
MediatR is a fantastic library, but recently, Jimmy Bogard has made an announcement to transitioned from a purely open-source model to a commercial one. I fully respect his decision. Here's why I choose custom mediator implementations:
- Zero External Dependencies — remove a third-party package and its upgrade surface.
- Full Control — implement exactly the pipeline behaviors (logging, validation, caching) you need.
- Simpler Debugging — step through your own code instead of a library's internals.
- Cost — Lack of support from Organisation on Licensing.
- Educational Value — a great exercise to understand the pattern deeply.
Think of it as trading a Swiss Army knife for a precision tool crafted specifically for your application's needs.
How we gonna do?
The implementation is intentionally small and focuses on the essentials. Here's how to build it step by step:
Step 1: Define Core Request and Handler Interfaces
Start by creating the foundational interfaces that will replace MediatR's IRequest
and
IRequestHandler
:
// Domain/Interfaces/IRequest.cs
namespace Domain.Interfaces;
public interface IRequest<TResult>
{
}
// Domain/Interfaces/IRequestHandler.cs
namespace Domain.Interfaces;
public interface IRequestHandler<TRequest, TResult> where TRequest : IRequest<TResult>
{
Task<TResult> Handle(TRequest request, CancellationToken cancellationToken);
}
Step 2: Add a Unit Value Type
Create a Unit type to represent "void"-style results, similar to MediatR's Unit:
// Domain/Utils/Unit.cs
namespace Domain.Utils;
/// <summary>
/// Represents a void type, since System.Void is not a valid return type in C#.
/// </summary>
public readonly struct Unit : IEquatable<Unit>
{
private static readonly Unit _value = new();
/// <summary>
/// Default and only value of the Unit type.
/// </summary>
public static ref readonly Unit Value => ref _value;
/// <summary>
/// Task from a Unit type.
/// </summary>
public static Task<Unit> Task { get; } = System.Threading.Tasks.Task.FromResult(_value);
public bool Equals(Unit other) => true;
public override bool Equals(object? obj) => obj is Unit;
public override int GetHashCode() => 0;
public static bool operator ==(Unit first, Unit second) => true;
public static bool operator !=(Unit first, Unit second) => false;
public override string ToString() => "()";
}
Step 3: Create the Mediator Dispatcher
Build the core mediator dispatcher that uses reflection to route requests to their handlers:
// Domain/Utils/Mediator.cs
using Domain.Interfaces;
namespace Domain.Utils;
public sealed class Mediator
{
private readonly IServiceProvider _provider;
public Mediator(IServiceProvider provider)
{
_provider = provider;
}
public async Task<T> Dispatch<T>(IRequest<T> request, CancellationToken cancellationToken = default)
{
var handlerInterface = typeof(IRequestHandler<,>).MakeGenericType(request.GetType(), typeof(T));
var handler = _provider.GetService(handlerInterface);
if (handler is null)
throw new InvalidOperationException($"No handler registered for request type {request.GetType()}");
dynamic dHandler = handler!;
T result = await dHandler.Handle((dynamic)request, cancellationToken);
return result;
}
}
Step 4: Club Request and Handler Together
Instead of separate files, keep commands and handlers together for better maintainability:
// Application/Features/Clients/Commands/CreateClient.cs
using Application.Features.Clients.Dtos;
using Domain.Interfaces;
using Domain.Utils;
namespace Application.Features.Clients.Commands;
public sealed class CreateClientCommand : IRequest<Result<ClientDto, BaseError>>
{
public string Name { get; set; } = string.Empty;
internal sealed class CreateClientCommandHandler(IUnitOfWork unitOfWork)
: IRequestHandler<CreateClientCommand, Result<ClientDto, BaseError>>
{
public async Task<Result<ClientDto, BaseError>> Handle(
CreateClientCommand request,
CancellationToken cancellationToken)
{
var clientNameResult = ClientName.Create(request.Name);
if (clientNameResult.IsFailure)
return Result.Failure<ClientDto, BaseError>(
BadRequestError.Create(clientNameResult.Error));
var client = new ClientEntity(clientNameResult.Value);
await unitOfWork.ClientRepository.AddAsync(client, cancellationToken);
var saveResult = await unitOfWork.SaveChangesAsync(cancellationToken);
if (saveResult.IsFailure)
return Result.Failure<ClientDto, BaseError>(
BadRequestError.Create(saveResult.Error));
var clientDto = ClientDto.FromEntity(client);
return Result.Success<ClientDto, BaseError>(clientDto);
}
}
}
Step 5: Automatic Handler Registration
Create an extension method to automatically discover and register all your handlers:
// Application/ApplicationServiceRegistration.cs
using System.Reflection;
using Domain.Interfaces;
public static class ApplicationServiceRegistration
{
public static IServiceCollection AddApplicationServices(this IServiceCollection services)
{
services.AddTransient<Mediator>();
RegisterHandlers(services);
return services;
}
private static void RegisterHandlers(IServiceCollection services)
{
var assembly = Assembly.GetExecutingAssembly();
var handlerTypes = assembly.GetTypes()
.Where(t => t.GetInterfaces()
.Any(i => i.IsGenericType &&
i.GetGenericTypeDefinition() == typeof(IRequestHandler<,>)))
.ToList();
foreach (var handlerType in handlerTypes)
{
var interfaceType = handlerType.GetInterfaces()
.First(i => i.IsGenericType &&
i.GetGenericTypeDefinition() == typeof(IRequestHandler<,>));
services.AddTransient(interfaceType, handlerType);
}
}
}
Step 6: Update API Endpoints
Replace ISender
or IMediator
with your custom Mediator
in controllers:
// PublicAPI/Client/Create.cs
using Application.Features.Clients.Commands;
using Application.Features.Clients.Dtos;
using Domain.Utils;
namespace PublicAPI.Client;
[ApiController]
[ApiVersion("1.0")]
public class Create(Mediator mediator) : ControllerBase
{
[HttpPost("api/clients")]
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(ClientDto))]
public async Task<ActionResult<ClientDto>> HandleAsync(
CreateClientCommand request,
CancellationToken cancellationToken = default)
{
var result = await mediator.Dispatch(request, cancellationToken);
return result.IsSuccess
? CreatedAtRoute("GetClient", new { id = result.Value.Id }, result.Value)
: result.Error.ToActionResult();
}
}
Step 7: Unit Testing Your Custom Mediator
Create comprehensive tests to verify your mediator works correctly:
// Tests/MediatorTests.cs
using Moq;
public class MediatorTests
{
[Test]
public async Task Dispatch_Command_ShouldCallCorrectHandler()
{
// Arrange
var serviceProvider = new Mock<IServiceProvider>();
var handler = new Mock<IRequestHandler<TestCommand, string>>();
var command = new TestCommand();
serviceProvider.Setup(x => x.GetService(typeof(IRequestHandler<TestCommand, string>)))
.Returns(handler.Object);
handler.Setup(x => x.Handle(command, It.IsAny<CancellationToken>()))
.ReturnsAsync("Success");
var mediator = new Mediator(serviceProvider.Object);
// Act
var result = await mediator.Dispatch(command);
// Assert
Assert.That(result, Is.EqualTo("Success"));
handler.Verify(x => x.Handle(command, It.IsAny<CancellationToken>()), Times.Once);
}
public class TestCommand : IRequest<string> { }
}
Migration Strategy
When migrating from MediatR to your custom mediator, follow this incremental approach :
- Start Small: Begin with one feature area (like Client management)
- Replace Interfaces: Update
IRequest
andIRequestHandler
usage - Update Controllers: Replace
ISender
/IMediator
with yourMediator
- Test Thoroughly: Ensure all functionality works as expected
- Gradual Migration: Move other feature areas one by one
Performance Considerations
Reflection-based dispatch has minimal overhead in most applications. For high-performance scenarios, consider:
- Handler Caching: Cache resolved handler types to avoid repeated reflection
- Source Generators: Use compile-time registration for zero-reflection dispatch
- Benchmarking: Measure actual performance impact in your specific use case
Summary
Replacing MediatR with a custom mediator implementation offers significant benefits: zero external dependencies, complete control over the request pipeline, simplified debugging, and educational value. The implementation is straightforward and maintains the same clean architecture benefits while being perfectly tailored to your application's needs.
This approach demonstrates that sometimes the best solution is building exactly what you need rather than depending on external libraries. Your custom mediator will be simpler, more focused, and perfectly suited to your application's requirements while maintaining all the architectural benefits of the mediator pattern. This can also be extended with decorator patterns to add cross-cutting concerns like logging, validation, or authorization without modifying the core mediator logic.