👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
Replacing MediatR with a Custom Mediator (Dynamic Dispatch) in WEB API

Replacing MediatR with a Custom Mediator (Dynamic Dispatch) in WEB API

Author - Abdul Rahman (Content Writer)

Web API

23 Articles

Improve

Table of Contents

  1. What we gonna do?
  2. Why we gonna do?
  3. How we gonna do?
  4. Summary

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 and IRequestHandler usage
  • Update Controllers: Replace ISender/IMediator with your Mediator
  • 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.

👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
  • Web API
  • MediatR
  • Mediator
  • DI
  • Dynamic Dispatch