CQRSMVCAugust 2026 · 8 min read

CQRS in ASP.NET Core MVC Without MediatR: Direct Handlers and Endpoints

TL;DR

CQRS does not require MediatR. In ASP.NET Core MVC you can separate commands and queries into plain handler classes, resolve them through DI, and call them directly from controllers and per-feature endpoints. This post builds the pattern in .NET 10 with no message bus.

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.

Want to see direct CQRS handlers in a real project?

MVC EDevKit Basic implements CQRS with direct handlers and per-feature endpoints across every admin slice, with full CRUD and validation. $21.

View MVC EDevKit Details