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.
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.
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 gave | Why teams liked it | Plain .NET replacement |
|---|---|---|
One ISender.Send(...) call | Controllers did not know the handler | A tiny dispatcher or direct handler call |
| Auto-wiring of handlers | No manual registration | DI scan or one line per handler |
| Pipeline behaviors | Logging, validation in one place | Decorators or a small behavior chain |
| Notifications (events) | One publish, many listeners | A 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
Steps
Audit
List every MediatR usage
Contracts
Define plain command and query interfaces
Dispatcher
Add a tiny sender
Migrate
Move handlers one module at a time
Remove
Delete the MediatR package
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.
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.
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 concern | MediatR way | Plain .NET way |
|---|---|---|
| Logging | IPipelineBehavior | Logging decorator or ILogger in handler |
| Validation | IPipelineBehavior | Validation decorator + FluentValidation |
| Caching | IPipelineBehavior | Caching decorator on queries |
| Transactions | IPipelineBehavior | Decorator 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
Steps
Orders
Calls the public contract
ICatalogApi
Small agreed interface
Catalog impl
Lives inside Catalog module
Catalog data
Private tables and rules
// 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}.
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.
| Situation | Keep MediatR? | Reason |
|---|---|---|
| Tiny team, education, non-profit | Maybe | Community edition is free for you |
| Large production app, want fewer dependencies | Refactor | Avoid licence cost and hidden indirection |
| Heavy reliance on third-party MediatR add-ons | Plan carefully | You must replace those add-ons too |
| Brand new project | Skip it | Start 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/Querysender 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
- MediatR and MassTransit Going Commercial: What This Means For You - Milan Jovanovic
- Refactoring A Modular Monolith Without MediatR in .NET - Anton Martyniuk
- Stop Conflating CQRS and MediatR - Milan Jovanovic
- Build Your Own CQRS Dispatcher in .NET (No MediatR) - codewithmukesh
- How to implement CQRS without MediatR - TheCodeMan
Related Patterns
Vertical Slice Architecture in .NET: The Easy Guide
Learn Vertical Slice Architecture in .NET in simple words. Organise code by feature instead of by layer, with diagrams, real examples, a comparison with Clean Architecture, and when to use each.
CQRS Pattern in .NET: The Way It Should Have Been From the Start
A friendly, hands-on guide to the CQRS pattern in .NET 10. Learn commands, queries, handlers, diagrams, and when to actually use it.
Stop Conflating CQRS and MediatR: They Are Not the Same Thing
CQRS and MediatR are two different ideas. Learn what each one really does, why people mix them up, and how to use CQRS in .NET with or without MediatR.
CQRS Pattern with MediatR in .NET: A Friendly Guide
Learn the CQRS pattern with MediatR in .NET using simple words, clear diagrams, and real C# code. Beginner friendly, with pitfalls and licensing notes.
CQRS Validation with MediatR Pipeline and FluentValidation in .NET
Learn centralized CQRS validation in .NET using a MediatR pipeline behavior and FluentValidation. Simple words, clear diagrams, and real C# code.
Getting Started with Event Sourcing in .NET with Marten and PostgreSQL
Learn event sourcing in .NET using Marten and PostgreSQL. Store events, build aggregates and projections, and read state the easy, beginner-friendly way.