Start Debugging

Migrate from MediatR to plain dependency injection in .NET 11

A step-by-step checklist to remove MediatR 12-14 and replace IRequest handlers, ISender, pipeline behaviors, and INotification with plain service classes and constructor injection.

Removing MediatR from a .NET 11 codebase is a mechanical refactor, not a rewrite. For a typical service with 50-150 handlers, budget half a day to a day: most handlers collapse one-to-one into methods on plain service classes, ISender.Send calls become direct interface calls, and the only part that needs design thought is the pipeline. What breaks is anything that relied on runtime handler resolution or IPipelineBehavior<,> wrapping every request; those become constructor injection and decorators. It is worth doing if you only ever call Send, if you are above MediatR’s $5,000,000 Community-edition revenue line and do not want to buy a commercial license, or if you want Native AOT and trim-friendliness. It is not worth doing if your pipeline behaviors are load-bearing across hundreds of request types.

Versions referenced: this guide covers removing MediatR 12.5.0 (the last Apache 2.0 release), 13.0 (released 2025-07-02, the first dual Reciprocal Public License 1.5 / commercial release from Lucky Penny Software), and the current 14.x line. The replacement code targets <TargetFramework>net11.0</TargetFramework> with the .NET 11 SDK and C# 14, plus Scrutor 6.x for decorators. If you are still deciding whether to leave, read MediatR vs plain service classes in 2026 first; this post assumes the decision is made.

Why teams are removing MediatR specifically now

What breaks

AreaChangeSeverity
ISender / IMediator injectionReplaced by the concrete service interface injected directlyhigh
IRequest<T> + IRequestHandler<,>Collapse into one interface + implementation methodhigh
IPipelineBehavior<,>No generic wrap point; replaced per interface by decorators or by middlewarehigh
INotification + INotificationHandler<>Replaced by an injected IEnumerable<IHandler> fan-out or an event aggregatormedium
AddMediatR(...) registrationReplaced by explicit AddScoped calls (or one Scrutor scan)medium
RequestHandlerDelegate<T> in behaviorsNo equivalent; the “next” chain is gonemedium
ISender.CreateStream (IStreamRequest)Replaced by a method returning IAsyncEnumerable<T>low
Unit tests mocking ISenderRe-pointed to mock the concrete interfacelow

Pre-flight checklist

# .NET 11 - inventory MediatR usage before touching anything
grep -rn "IRequestHandler\|IRequest<\|: IRequest" src/      # handlers + requests
grep -rn "IPipelineBehavior" src/                            # the hard part
grep -rn "INotification" src/                                # fan-out
grep -rn "ISender\|IMediator\|\.Send(\|\.Publish(" src/      # call sites

Migration steps

  1. Convert each request and handler into a service interface and method.

A MediatR request is a message type plus a separate handler. Replace both with one interface and one implementation. Group related requests onto a single service rather than creating one interface per old request.

// .NET 11, C# 14, MediatR 14.x - BEFORE
using MediatR;

public record GetOrderById(int OrderId) : IRequest<OrderDto>;

public sealed class GetOrderByIdHandler(AppDbContext db)
    : IRequestHandler<GetOrderById, OrderDto>
{
    public async Task<OrderDto> Handle(GetOrderById request, CancellationToken ct)
    {
        var order = await db.Orders.FindAsync([request.OrderId], ct)
            ?? throw new OrderNotFoundException(request.OrderId);
        return order.ToDto();
    }
}
// .NET 11, C# 14 - AFTER: plain service, no MediatR reference
public interface IOrderService
{
    Task<OrderDto> GetByIdAsync(int orderId, CancellationToken ct);
}

public sealed class OrderService(AppDbContext db) : IOrderService
{
    public async Task<OrderDto> GetByIdAsync(int orderId, CancellationToken ct)
    {
        var order = await db.Orders.FindAsync([orderId], ct)
            ?? throw new OrderNotFoundException(orderId);
        return order.ToDto();
    }
}

Verify: the new file has no using MediatR; and the handler body is byte-identical apart from the method signature. The request record parameters become method parameters in the same order.

  1. Replace ISender.Send call sites with direct interface calls.

Every caller that injected ISender or IMediator now injects the specific service interface and calls the method.

// .NET 11, C# 14 - BEFORE
public async Task<OrderDto> Get(int id, ISender sender, CancellationToken ct)
    => await sender.Send(new GetOrderById(id), ct);

// AFTER
public async Task<OrderDto> Get(int id, IOrderService orders, CancellationToken ct)
    => await orders.GetByIdAsync(id, ct);

Verify: after this step, grep -rn "ISender\|IMediator" src/ returns only lines you have deliberately not migrated yet. The count should march toward zero.

  1. Register the services explicitly.

Delete AddMediatR(...) and register each service. Explicit registration is trim-safe and fails fast at startup, which is exactly the failure mode behind Unable to resolve service for type while attempting to activate.

// .NET 11, C# 14 - BEFORE
builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssemblyContaining<GetOrderById>());

// AFTER - explicit, or one Scrutor scan if you prefer convention
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<ICustomerService, CustomerService>();

If you want convention-based registration without MediatR’s reflection model, Scrutor scans by interface naming:

// .NET 11, C# 14, Scrutor 6.x - register every *Service against its interface
builder.Services.Scan(scan => scan
    .FromAssemblyOf<OrderService>()
    .AddClasses(c => c.Where(t => t.Name.EndsWith("Service")))
    .AsImplementedInterfaces()
    .WithScopedLifetime());

Verify: the app boots. Run it and hit one migrated endpoint; a missing registration now throws at startup, not on first request. Confirm no scoped-from-singleton mistakes have crept in, the class of bug covered in Cannot consume scoped service from singleton.

  1. Replace pipeline behaviors with decorators or middleware.

This is the only step that needs judgment. A IPipelineBehavior<,> wrapped every request in one place. You have two honest replacements.

For concerns that are genuinely per-service (logging, caching, retries), use a Scrutor decorator:

// .NET 11, C# 14, Scrutor 6.x - BEFORE was a generic ValidationBehavior<TRequest,TResponse>
public sealed class LoggingOrderService(
    IOrderService inner,
    ILogger<LoggingOrderService> logger) : IOrderService
{
    public async Task<OrderDto> GetByIdAsync(int orderId, CancellationToken ct)
    {
        logger.LogInformation("Fetching order {OrderId}", orderId);
        return await inner.GetByIdAsync(orderId, ct);
    }
}

// Registration: wrap the real implementation
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.Decorate<IOrderService, LoggingOrderService>();

For concerns that are really HTTP concerns (request logging, exception-to-ProblemDetails mapping, auth), move them out of the application layer entirely into ASP.NET Core middleware or an endpoint filter. A behavior that caught exceptions and shaped responses belongs in the same place as a global exception filter in ASP.NET Core 11, not in a decorator.

If you used FluentValidation inside a ValidationBehavior, keep the validators and call them from a single decorator or from a minimal-API endpoint filter:

// .NET 11, C# 14 - a validation decorator replacing ValidationBehavior for one interface
public sealed class ValidatingOrderService(
    IOrderService inner,
    IValidator<CreateOrder> validator) : IOrderService
{
    public async Task<OrderDto> CreateAsync(CreateOrder cmd, CancellationToken ct)
    {
        await validator.ValidateAndThrowAsync(cmd, ct);
        return await inner.CreateAsync(cmd, ct);
    }

    public Task<OrderDto> GetByIdAsync(int orderId, CancellationToken ct)
        => inner.GetByIdAsync(orderId, ct);
}

Verify: write or keep a test that asserts the cross-cutting concern still runs, for example that an invalid command throws ValidationException before touching the database. Run dotnet test and confirm the behavior tests pass.

  1. Replace INotification fan-out.

MediatR’s Publish invoked every INotificationHandler<T>. Replace it with an injected IEnumerable<T> of a small handler interface you define, and a thin publisher.

// .NET 11, C# 14 - BEFORE: INotification + handlers, AFTER: explicit fan-out
public interface IOrderPlacedHandler
{
    Task HandleAsync(OrderPlaced evt, CancellationToken ct);
}

public sealed class OrderEventPublisher(IEnumerable<IOrderPlacedHandler> handlers)
{
    public async Task PublishAsync(OrderPlaced evt, CancellationToken ct)
    {
        foreach (var handler in handlers)
            await handler.HandleAsync(evt, ct);
    }
}

// Registration - each handler registered against the interface
builder.Services.AddScoped<IOrderPlacedHandler, SendConfirmationEmail>();
builder.Services.AddScoped<IOrderPlacedHandler, UpdateInventory>();
builder.Services.AddScoped<OrderEventPublisher>();

The container hands you every registered handler in IEnumerable<IOrderPlacedHandler>, so adding a handler is one registration line, the same ergonomics MediatR gave you. If you publish many event types, generate the publisher with a generic IEventPublisher<T> instead of one per event.

Verify: assert that publishing an event invokes all registered handlers. A test with two fake handlers that both record a call is enough.

  1. Remove the package and the last references.

Once the call sites are gone, drop the dependency.

# .NET 11 - remove the package from every project that referenced it
dotnet remove package MediatR
grep -rn "MediatR" src/ || echo "clean"

Verify: grep -rn "MediatR" src/ prints clean. The build has no unresolved using MediatR; and dotnet build -c Release succeeds with zero warnings about a missing package.

Verification: the smoke test after the migration

Run this checklist top to bottom and do not skip the perf line:

Rollback plan

This migration is reversible per commit but tedious to undo wholesale, so stage it. Keep each of the six steps as a separate commit, and migrate one vertical slice (one service interface and its callers) at a time rather than the whole solution at once. MediatR and plain services can coexist during the transition: leave AddMediatR registered for the un-migrated handlers while you convert the rest. If a slice misbehaves, revert that slice’s commits and the rest of the app is unaffected. There is no data or schema change here, so rollback is purely a code revert with no migration to reverse.

Gotchas we hit

IPipelineBehavior order does not map cleanly to decorators. MediatR runs behaviors in registration order around one handler. Scrutor decorators nest in the order you call Decorate, and the last registered decorator is the outermost wrapper. Get the order wrong and your logging decorator runs inside your validation decorator instead of around it. Write one ordering test per decorated interface.

ISender inside a handler calling another handler becomes a direct service call, and that can expose a cycle. MediatR’s indirection hid handler-to-handler dependencies. When OrderService injects ICustomerService and CustomerService injects IOrderService, the container throws at startup with a circular dependency. That is the migration surfacing a design problem MediatR was masking; break the cycle by extracting the shared logic into a third service.

Streaming requests need IAsyncEnumerable<T>, not Task<T>. If you used IStreamRequest<T> and CreateStream, the replacement method returns IAsyncEnumerable<T> and uses yield return. Do not collapse it into a Task<List<T>>; that changes the streaming semantics and can blow memory on large results, the same trap described in reading a large CSV in .NET 11 without running out of memory.

Tests that mocked ISender silently pass against nothing. A test that set up sender.Send(...) to return a value will compile-error once ISender is gone, which is good. But a test that injected a real IMediator and asserted behavior through it needs re-pointing at the concrete interface. Re-run the full suite, do not trust a green build alone.

This is the kind of dependency rationalization that pays off the same way choosing the in-box serializer does in System.Text.Json vs Newtonsoft.Json in 2026: one fewer third-party library on the hot path, one fewer licensing question, and code that a new teammate can navigate without learning a dispatch convention first.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back