Skip to main content
SEMastery

Refactoring a Modular Monolith Without MediatR in .NET

Learn to remove MediatR from a .NET modular monolith using plain handlers and a tiny dispatcher, with CQRS, pipeline behaviors, and clear module boundaries.

13 min readUpdated May 29, 2026

Refactoring a Modular Monolith Without MediatR in .NET

Imagine a busy school office with one helpful clerk at the front desk. Every student who needs something - a marksheet, a leave form, a library card - goes to that one clerk. The clerk does not do the work. They just look at your slip, walk to the right room, and hand your request to the correct teacher. That clerk is a mediator.

This works fine when the school is small. But when the school grows to 2,000 students, that single clerk becomes a bottleneck. Worse, when a parent asks "who actually handled my form?", nobody can tell, because everything went through the clerk first. You have to chase the paper trail through the clerk's desk before you find the real person who did the job.

In a .NET project, MediatR is that front-desk clerk. It takes a request and quietly finds the right handler. For years this was a lovely pattern. But two things changed. First, MediatR went commercial in July 2025. Second, many teams realised that for a single application, the clerk was adding confusion without adding much value.

This post shows you how to refactor a modular monolith so it no longer needs MediatR. We will keep all the good ideas - CQRS, clean modules, cross-cutting behaviors - but we will use plain C# classes you can read top to bottom.

What is a modular monolith again?

A monolith is one application, deployed as one unit. A modular monolith is still one application, but the code inside is split into clear modules - like Orders, Catalog, and Shipping - each with its own folder, its own rules, and a small public door that other modules use to talk to it.

A modular monolith: one app, several modules, each with a clear public surface.

The key promise of a modular monolith is discipline. Modules should not reach into each other's private code. They should only talk through small, agreed contracts. MediatR was often used as the glue for this. The good news: you do not need it. Plain interfaces and the built-in .NET dependency injection (DI) container do the job well.

Why people used MediatR in the first place

It helps to be honest about what MediatR gave us, so we know what to replace.

Feature MediatR gaveWhy teams liked itPlain .NET replacement
One ISender.Send(...) callControllers did not know the handlerA tiny dispatcher or direct handler call
Auto-wiring of handlersNo manual registrationDI scan or one line per handler
Pipeline behaviorsLogging, validation in one placeDecorators or a small behavior chain
Notifications (events)One publish, many listenersA simple event publisher interface

Notice that none of these need a third-party library. They are all normal patterns that ship with C# and ASP.NET Core. MediatR packaged them nicely, but the package is no longer free for every team.

The licensing change, in plain words

On 2 July 2025, MediatR moved to a dual-license model under its new owner, Lucky Penny Software. New versions (from v13) ship under the Reciprocal Public License plus a commercial license. There is a free Community edition for smaller companies (under about 5M USD revenue), non-profits, schools, and non-production use. Larger production teams now need a paid license. MassTransit and AutoMapper announced similar moves.

This is not a complaint about the authors - maintaining open source for years is hard, and they deserve to be paid. But it does mean every team should ask a simple question: do we actually need this library, or can we use plain code? For a single modular monolith, the answer is usually "plain code is fine".

The plan for our refactor

We will move in small, safe steps. Never rip everything out at once.

Refactor in safe steps

Audit
Contracts
Dispatcher
Migrate
Remove

Steps

1

Audit

List every MediatR usage

2

Contracts

Define plain command and query interfaces

3

Dispatcher

Add a tiny sender

4

Migrate

Move handlers one module at a time

5

Remove

Delete the MediatR package

Each step keeps the app working before moving on.

The order matters. We add the new pieces first, let old and new live side by side, then delete MediatR only at the very end. This way the app keeps running after every commit.

Step 1: Define plain CQRS contracts

CQRS stands for Command Query Responsibility Segregation. It is a long name for a simple idea: a command changes data (place order, cancel order), and a query reads data (get order, list orders). We keep them separate so each stays small and focused.

Here are the contracts. They look a lot like MediatR's, on purpose, so migration is gentle.

// Marker for a query that returns a result.
public interface IQuery<TResult> { }
 
// Marker for a command that changes state and returns a result.
public interface ICommand<TResult> { }
 
// A handler that knows how to answer one query.
public interface IQueryHandler<TQuery, TResult>
    where TQuery : IQuery<TResult>
{
    ValueTask<TResult> Handle(TQuery query, CancellationToken ct);
}
 
// A handler that knows how to run one command.
public interface ICommandHandler<TCommand, TResult>
    where TCommand : ICommand<TResult>
{
    ValueTask<TResult> Handle(TCommand command, CancellationToken ct);
}

We return ValueTask<TResult> instead of Task<TResult>. For handlers that finish quickly, ValueTask avoids allocating an object on the heap. Over millions of calls, that is real memory saved.

Now a concrete example from the Orders module:

public sealed record GetOrderById(Guid OrderId) : IQuery<OrderDto?>;
 
public sealed class GetOrderByIdHandler
    : IQueryHandler<GetOrderById, OrderDto?>
{
    private readonly OrdersDbContext _db;
 
    public GetOrderByIdHandler(OrdersDbContext db) => _db = db;
 
    public async ValueTask<OrderDto?> Handle(
        GetOrderById query, CancellationToken ct)
    {
        return await _db.Orders
            .Where(o => o.Id == query.OrderId)
            .Select(o => new OrderDto(o.Id, o.Total, o.Status))
            .FirstOrDefaultAsync(ct);
    }
}

Notice there is no magic here. You can press F12 on GetOrderByIdHandler and read exactly what happens. With MediatR, jumping from Send to the handler often meant guessing or using a plugin. This direct, readable path is one of the biggest reasons teams refactor.

Step 2: A tiny dispatcher (optional)

You can call handlers directly by injecting IQueryHandler<GetOrderById, OrderDto?> into your endpoint. That is the simplest option and many teams stop there. But if you like the single Send(...) style, a small dispatcher gives you that without MediatR.

public interface IDispatcher
{
    ValueTask<TResult> Query<TResult>(
        IQuery<TResult> query, CancellationToken ct = default);
 
    ValueTask<TResult> Send<TResult>(
        ICommand<TResult> command, CancellationToken ct = default);
}
 
public sealed class Dispatcher : IDispatcher
{
    private readonly IServiceProvider _sp;
    public Dispatcher(IServiceProvider sp) => _sp = sp;
 
    public ValueTask<TResult> Query<TResult>(
        IQuery<TResult> query, CancellationToken ct = default)
    {
        var handlerType = typeof(IQueryHandler<,>)
            .MakeGenericType(query.GetType(), typeof(TResult));
        dynamic handler = _sp.GetRequiredService(handlerType);
        return handler.Handle((dynamic)query, ct);
    }
 
    public ValueTask<TResult> Send<TResult>(
        ICommand<TResult> command, CancellationToken ct = default)
    {
        var handlerType = typeof(ICommandHandler<,>)
            .MakeGenericType(command.GetType(), typeof(TResult));
        dynamic handler = _sp.GetRequiredService(handlerType);
        return handler.Handle((dynamic)command, ct);
    }
}

This version uses a little reflection per call. For most apps that is completely fine. If you measure a hot path and want it faster, you can cache typed wrappers in a FrozenDictionary<Type, ...> built once at startup, which removes the per-call reflection and runs in O(1). Public benchmarks of such hand-rolled dispatchers show them running several times faster than MediatR with far less memory used - but for clarity, start simple and optimise only if a profiler tells you to.

How a request flows through the dispatcher to the right handler.

Step 3: Register handlers in DI

We need to tell the .NET container about each handler so it can build them. You can write one line per handler, but a small assembly scan keeps it tidy as the app grows.

public static IServiceCollection AddHandlers(
    this IServiceCollection services, Assembly assembly)
{
    var handlerInterfaces = new[]
    {
        typeof(IQueryHandler<,>),
        typeof(ICommandHandler<,>)
    };
 
    var types = assembly.GetTypes()
        .Where(t => t is { IsAbstract: false, IsInterface: false });
 
    foreach (var type in types)
    {
        foreach (var iface in type.GetInterfaces()
            .Where(i => i.IsGenericType
                && handlerInterfaces.Contains(i.GetGenericTypeDefinition())))
        {
            services.AddScoped(iface, type);
        }
    }
 
    return services;
}

Each module calls this with its own assembly. That keeps registration local to the module, which is exactly the boundary we want in a modular monolith.

// In each module's setup
services.AddHandlers(typeof(OrdersModule).Assembly);
services.AddHandlers(typeof(CatalogModule).Assembly);
services.AddSingleton<IDispatcher, Dispatcher>();

Step 4: Replace pipeline behaviors

MediatR's most loved feature was pipeline behaviors - one place to add logging, validation, or caching around every handler. We can keep this idea with the decorator pattern. A decorator wraps a handler and adds a little before-and-after work.

A decorator chain wraps the real handler with cross-cutting steps.

Here is a validation decorator for commands. It runs validators first, then calls the inner handler only if the input is valid.

public sealed class ValidationCommandHandler<TCommand, TResult>
    : ICommandHandler<TCommand, TResult>
    where TCommand : ICommand<TResult>
{
    private readonly ICommandHandler<TCommand, TResult> _inner;
    private readonly IEnumerable<IValidator<TCommand>> _validators;
 
    public ValidationCommandHandler(
        ICommandHandler<TCommand, TResult> inner,
        IEnumerable<IValidator<TCommand>> validators)
    {
        _inner = inner;
        _validators = validators;
    }
 
    public async ValueTask<TResult> Handle(
        TCommand command, CancellationToken ct)
    {
        foreach (var validator in _validators)
            await validator.ValidateAndThrowAsync(command, ct);
 
        return await _inner.Handle(command, ct);
    }
}

You can register decorators by hand, or use a small helper like Scrutor's Decorate method. The point is that cross-cutting logic still lives in one place, but now it is plain, debuggable C# with no hidden pipeline.

Cross-cutting concernMediatR wayPlain .NET way
LoggingIPipelineBehaviorLogging decorator or ILogger in handler
ValidationIPipelineBehaviorValidation decorator + FluentValidation
CachingIPipelineBehaviorCaching decorator on queries
TransactionsIPipelineBehaviorDecorator that opens an EF Core transaction

Step 5: Module-to-module communication

In a modular monolith, the Orders module sometimes needs something from the Catalog module - for example, the price of a product. With MediatR, people often sent a request across modules. Without it, we use a public contract interface owned by the target module.

Modules talk through public contracts

Orders
ICatalogApi
Catalog impl
Catalog data

Steps

1

Orders

Calls the public contract

2

ICatalogApi

Small agreed interface

3

Catalog impl

Lives inside Catalog module

4

Catalog data

Private tables and rules

Orders depends on an interface, not on Catalog's private code.
// Public contract, exposed by the Catalog module
public interface ICatalogApi
{
    ValueTask<decimal?> GetPriceAsync(Guid productId, CancellationToken ct);
}
 
// Implementation stays private inside Catalog
internal sealed class CatalogApi : ICatalogApi
{
    private readonly CatalogDbContext _db;
    public CatalogApi(CatalogDbContext db) => _db = db;
 
    public async ValueTask<decimal?> GetPriceAsync(
        Guid productId, CancellationToken ct) =>
        await _db.Products
            .Where(p => p.Id == productId)
            .Select(p => (decimal?)p.Price)
            .FirstOrDefaultAsync(ct);
}

Orders depends only on ICatalogApi. It never sees Catalog's tables or internal classes. This is cleaner than a generic mediator because the contract is named and explicit - you can see exactly what one module promises to another.

Step 6: In-process events for "tell, don't ask"

Sometimes a module wants to announce that something happened - "an order was placed" - without caring who listens. MediatR notifications did this. We can keep it with a tiny publisher.

public interface IDomainEvent { }
 
public interface IDomainEventHandler<TEvent>
    where TEvent : IDomainEvent
{
    ValueTask Handle(TEvent domainEvent, CancellationToken ct);
}
 
public sealed class EventPublisher
{
    private readonly IServiceProvider _sp;
    public EventPublisher(IServiceProvider sp) => _sp = sp;
 
    public async ValueTask Publish<TEvent>(
        TEvent domainEvent, CancellationToken ct)
        where TEvent : IDomainEvent
    {
        var handlers = _sp
            .GetServices<IDomainEventHandler<TEvent>>();
 
        foreach (var handler in handlers)
            await handler.Handle(domainEvent, ct);
    }
}

The Shipping module can register a handler for OrderPlaced and start preparing a shipment, while Orders simply publishes the event and moves on. For events that must survive a crash or cross a real boundary later, pair this with the outbox pattern so the event is saved in the same database transaction as the data change.

A complete request, end to end

Let us trace one real request so the pieces click together: a user asks for an order by its id, say GET /orders/{id}.

End-to-end path with no MediatR in sight.

The endpoint stays thin. It maps the route, builds the query record, and asks the dispatcher. Everything after that is plain, traceable code.

app.MapGet("/orders/{id:guid}", async (
    Guid id, IDispatcher dispatcher, CancellationToken ct) =>
{
    var order = await dispatcher.Query(new GetOrderById(id), ct);
    return order is null ? Results.NotFound() : Results.Ok(order);
});

Trade-offs: be honest

Removing MediatR is not always the right call. Here is a fair view.

SituationKeep MediatR?Reason
Tiny team, education, non-profitMaybeCommunity edition is free for you
Large production app, want fewer dependenciesRefactorAvoid licence cost and hidden indirection
Heavy reliance on third-party MediatR add-onsPlan carefullyYou must replace those add-ons too
Brand new projectSkip itStart with plain handlers from day one

The biggest hidden cost of refactoring is time and testing. Do it module by module, keep good tests, and never mix the refactor with new features in the same pull request.

Quick recap

  • A modular monolith is one app split into clear modules with small public doors. MediatR was often the glue, but it is not required.
  • MediatR went commercial on 2 July 2025. There is a free Community edition for small or non-production use, but many production teams now need a paid licence.
  • CQRS does not need MediatR. A few plain interfaces - IQuery, ICommand, and their handlers - give you the same separation, and you can read the code top to bottom.
  • A tiny dispatcher is optional. You can inject handlers directly, or add a small Send/Query sender if you like the single-call style.
  • Pipeline behaviors become decorators: logging, validation, caching, and transactions still live in one place, just as plain C#.
  • Modules talk through named public contracts like ICatalogApi, and announce facts through a small event publisher - both clearer than a generic mediator.
  • Refactor in safe steps: audit, add contracts, add a dispatcher, migrate one module at a time, then remove the package last.

References and further reading

Related Patterns