CQRS with MediatR in a Real ASP.NET Core App: Worth It?

Mobishare splits every operation into a MediatR command or query, one file each, thin controllers throughout. Here's what that buys you — and the swallowed-exception bug it let slip through.

Simone Negro, Backend & AI Engineer
6 min read

CQRS gets pitched as a pattern for systems with genuinely different read and write models — a reporting database shaped nothing like the transactional one, eventual consistency between them, the whole apparatus. MobiShare doesn’t need any of that; it’s a bike-sharing platform on a single SQLite file. It still uses CQRS-shaped code throughout, via MediatR, and revisiting it a year later, I think that’s the right read of what the pattern is actually good for at this scale — even though it also hid a real bug for months.

What “CQRS via MediatR” means once you strip the theory

Forget the distributed-systems version for a second. In practice, MediatR-flavored CQRS in a single-database ASP.NET Core app means: every distinct thing your app does — create a ride, reserve a vehicle, file a report — becomes its own class implementing IRequest<TResponse>, paired with a handler implementing IRequestHandler<TRequest, TResponse>. Controllers stop containing logic; they construct a request object and call _mediator.Send(request). That’s the entire pattern. No event sourcing, no separate read database, no eventual consistency — just a very deliberate constraint on where code is allowed to live.

Mobishare.Core has no service layer in the traditional sense. Instead there’s Requests/Vehicles/RideRequests/Commands/CreateRide.cs, Requests/Users/BalanceRequest/Commands/CreateBalance.cs, and roughly forty siblings — each defining both the request type and its handler side by side, so the operation and its implementation are never more than a scroll apart:

public class CreateRide : Ride, IRequest<Ride> { }

public class CreateRideHandler : IRequestHandler<CreateRide, Ride>
{
    public async Task<Ride> Handle(CreateRide request, CancellationToken cancellationToken)
    {
        var newRide = _mapper.Map<Ride>(request);
        // ...
        await _dbContext.SaveChangesAsync(cancellationToken);
        return newRide;
    }
}

And every controller collapses to the same three lines regardless of what it’s fronting:

[HttpPost()]
public async Task<IActionResult> CreateRide([FromBody] CreateRide createRide)
{
    if (createRide == null) return BadRequest("Invalid request payload.");
    var result = await _mediator.Send(createRide);
    return CreatedAtAction(nameof(CreateRide), new { id = result.Id }, result);
}

What this constraint actually buys you

No fat controller ever accumulates business logic here, because there’s structurally nowhere for it to go except its own handler file. That’s the real, non-hype payoff, and it’s a maintenance property, not a performance one: change how vehicle reservation works, and the change is scoped to ReserveVehicle.cs and nothing else. Review a pull request that touches ride creation, and git diff alone tells you the entire blast radius — there’s no fat RideService.cs three thousand lines away that might also be affected. For a codebase multiple people touch under a deadline, each in their own vertical slice of the domain, that’s worth the extra file-per-operation ceremony on its own; you rarely need the distributed-systems half of CQRS to get it.

The alternative this is actually being compared to

It’s worth being concrete about what MobiShare didn’t do, because “CQRS vs. no CQRS” is a less useful comparison than “CQRS vs. the fat controller this would otherwise have been.” Without MediatR, RideController would own the mapping, the DbContext call, the logging, and the error handling directly — fine for one action, and the usual place a controller starts accumulating a second action, then a validation helper shared between them, then a private method that quietly becomes load-bearing for three different endpoints. None of that is CQRS’s problem to solve, but MediatR removes the temptation structurally: there is no shared class for logic to accumulate in, because every operation gets its own file by construction. The discipline isn’t “we agreed not to write fat controllers.” It’s “the pattern doesn’t offer a place to put one.”

The cost is real and worth naming too: forty small files instead of, say, six controllers, and a _mediator.Send(x) indirection between every controller action and the code that actually runs it — anyone new to the codebase has to learn “go find the handler” as a reflex before they can trace anything. For a solo project or a two-week prototype, that’s overhead without payoff. For a team shipping under deadline, where four people are touching four different verticals of the same domain simultaneously, it’s the opposite trade: the indirection is the thing that lets four people not collide.

Where the pattern stops helping

Look again at CreateRideHandler.Handle — the full version, not the excerpt:

try
{
    _dbContext.Rides.Entry(newRide).State = EntityState.Added;
    await _dbContext.SaveChangesAsync(cancellationToken);
    _logger.LogInformation("Ride {RideId} created successfully", newRide);
}
catch (Exception ex)
{
    _logger.LogError(ex, "Error creating new ride");
}
return newRide;

If SaveChangesAsync throws — a constraint violation, a dropped connection, anything at all — the exception is logged and swallowed, and the method returns newRide regardless: an in-memory object with no database row behind it, indistinguishable from a real success to the controller, which happily returns 201 Created for a ride that was never persisted. TechnicianReportServiceHandler does the same thing one layer over — catch, log, return an empty result as if that were a legitimate answer rather than a failure.

This is the actual failure mode of CQRS-via-MediatR done this way, and it’s important to be precise about what caused it, because it isn’t the pattern itself: a traditional fat-service method with the identical try/catch would be exactly as broken. CQRS didn’t introduce this bug. What it did was make every handler in the folder look uniformly trustworthy — same shape, same size, same “thin wrapper around a DbContext call” silhouette — which is precisely the condition under which nobody double-checks the one handler that silently isn’t playing by the same rules as the other thirty-nine.

What would have actually caught it

Not a lint rule, and not “write more tests” as a platitude — specifically, a single integration test per handler asserting that a forced SaveChangesAsync failure surfaces as an exception or an error result, never a fake success object. Mobishare.Tests is fully scaffolded for exactly this: xUnit, Moq, Microsoft.AspNetCore.Mvc.Testing, a folder tree that mirrors the CQRS structure one-to-one, Integration/Vehicles/Ride/Commands sitting right there waiting for a test class. Zero tests were ever written into it. The uniform shape of MediatR handlers that makes this bug easy to miss on manual review is exactly what makes it cheap to test systematically — one test template, forty near-identical handlers to run it against — and that leverage went unused.

What I checkedResult
Command/query files in Mobishare.Core/Requests~40
Controllers with business logic beyond _mediator.Send0
Handlers found swallowing an exception and returning a fake success2 confirmed (CreateRideHandler, TechnicianReportServiceHandler), likely more given the repeated shape
Test files in the scaffolded Mobishare.Tests project0

Verdict

CQRS-via-MediatR earns its ceremony in a codebase like this — thin controllers and a one-file-per-operation seam are a real, durable maintainability win, and you don’t need the distributed-systems justification to adopt it. But the pattern is not a substitute for making failure loud, and it will actively camouflage a handler that swallows exceptions by making it look exactly as clean as the thirty-nine handlers next to it that don’t. If you adopt this pattern, put “does this handler propagate failure or catch-and-return-anyway” on your review checklist explicitly, and write the one generic integration test that checks it across every handler — the uniform structure that makes the pattern pleasant to read is the same structure that makes that test cheap to replicate.

Get new posts by email

No hype, unsubscribe anytime. · Powered by Buttondown

Or follow along

Shorter takes, half-finished ideas, and whatever I'm building or breaking this week.