CQRS — Command Query Responsibility Segregation — gets conflated with MediatR in most tutorials, but the two are independent ideas. CQRS is the discipline of separating write operations (commands) from read operations (queries). MediatR is an in-process message dispatcher that some teams use to deliver those commands and queries. In an ASP.NET Core MVC application you can get every benefit of CQRS with nothing more than plain handler classes registered in DI and called directly from your controllers and endpoints.
Why You Might Not Need MediatR in an MVC App
MediatR shines when you want pipeline behaviors, publish/subscribe, and a single dispatch point for cross-cutting concerns. In a classic MVC controller, those concerns are already handled by filters, action attributes, and the middleware pipeline. A controller that calls a query handler directly is easy to debug, easy to test, and has zero framework magic. If your app is a vertical-slice MVC monolith, direct handlers keep the slice self-contained: the command, its validator, and its handler all live in the same feature folder with no extra dependency.
A Query Handler Read Directly From the Controller
Queries return data. The query handler uses EF Core with no-tracking reads for performance and returns a view model the Razor view can render directly:
public sealed class GetCurrencyListQuery
{
public int Page { get; set; } = 1;
public string? Search { get; set; }
}
public sealed class GetCurrencyListHandler
{
private readonly AppDbContext _db;
public GetCurrencyListHandler(AppDbContext db) => _db = db;
public async Task<PagedResult<CurrencyListItem>> HandleAsync(
GetCurrencyListQuery query, CancellationToken ct)
{
var q = _db.Currencies.AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Search))
q = q.Where(c => c.Code.Contains(query.Search) || c.Name.Contains(query.Search));
var total = await q.CountAsync(ct);
var items = await q.OrderBy(c => c.Code)
.Skip((query.Page - 1) * 10).Take(10)
.Select(c => new CurrencyListItem { Id = c.Id, Code = c.Code, Name = c.Name, Rate = c.Rate })
.ToListAsync(ct);
return new PagedResult<CurrencyListItem>(items, total, query.Page);
}
}
The controller resolves the handler through constructor injection and calls it. No dispatcher, no request/response wrappers, no pipeline:
public class CurrencyController : Controller
{
private readonly GetCurrencyListHandler _listHandler;
public CurrencyController(GetCurrencyListHandler listHandler)
=> _listHandler = listHandler;
[HttpGet]
public async Task<IActionResult> Index([FromQuery] int page = 1)
{
var result = await _listHandler.HandleAsync(new GetCurrencyListQuery { Page = page });
return View(result);
}
}
A Command Handler and Validation
Commands mutate state and return a minimal result such as the created entity id. Validation runs inside the handler or through a FluentValidation validator in the same slice:
public sealed class CreateCurrencyCommand
{
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public decimal Rate { get; set; }
}
public sealed class CreateCurrencyHandler
{
private readonly AppDbContext _db;
public CreateCurrencyHandler(AppDbContext db) => _db = db;
public async Task<int> HandleAsync(CreateCurrencyCommand command, CancellationToken ct)
{
var entity = new Currency
{
Code = command.Code.ToUpperInvariant(),
Name = command.Name,
Rate = command.Rate
};
_db.Currencies.Add(entity);
await _db.SaveChangesAsync(ct);
return entity.Id;
}
}
The write path uses full change tracking, the read path uses no-tracking. That single distinction is the heart of CQRS, and it needs no library at all.
Exposing the Same Feature as REST Endpoints
The same handlers back the MVC UI and the REST API. A per-feature Endpoints file maps the operations for API clients:
public static class CurrencyEndpoint
{
public static void Map(IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/currencies").RequireAuthorization();
group.MapGet("/", async (GetCurrencyListHandler h, int page = 1, CancellationToken ct)
=> Results.Ok(await h.HandleAsync(new GetCurrencyListQuery { Page = page }, ct)));
group.MapPost("/", async (CreateCurrencyCommand cmd, CreateCurrencyHandler h, CancellationToken ct)
=> Results.Created($"/api/currencies/{await h.HandleAsync(cmd, ct)}", cmd));
}
}
One feature, two surfaces, zero duplication. The MVC controller serves the interactive UI, the endpoint serves the API, and both delegate to the same CQRS handler classes — the exact architecture implemented in the MVC EDevKit Basic source code.
Registering Handlers in DI
Register each handler class as a scoped service in Program.cs. Because the handlers depend only on AppDbContext, this is a few lines:
builder.Services.AddScoped<GetCurrencyListHandler>();
builder.Services.AddScoped<CreateCurrencyHandler>();
builder.Services.AddScoped<UpdateCurrencyHandler>();
builder.Services.AddScoped<DeleteCurrencyHandler>();
If the handler list grows, a one-line registration convention (scan an assembly and register all *Handler classes) keeps Program.cs clean.
Key Takeaways
- CQRS and MediatR are independent — CQRS works perfectly with plain handler classes in MVC
- Queries use no-tracking reads; commands use full change tracking
- Controllers call query and command handlers directly via constructor injection
- Per-feature endpoints expose the same handlers to REST clients without duplication
- Direct handlers keep vertical slices self-contained, testable, and free of framework magic
FAQ
Is MediatR required for CQRS in ASP.NET Core MVC? No. MediatR is optional. You can separate commands and queries into plain classes and call them directly from controllers with dependency injection.
When should I add MediatR anyway? When you need pipeline behaviors applied to every command (validation, logging, transactions) or publish/subscribe events. Otherwise direct handlers are simpler.
Should queries always use AsNoTracking? Yes, for read-only queries it avoids the change tracker overhead and is the standard performance practice in CQRS read models.
Can the MVC controller and the REST endpoint share the same handler? Absolutely. That is the point — one handler, two entry points, no duplicated business logic.