Almost every long-lived ASP.NET Core MVC application tells the same story. A controller starts with one action and two dependencies. Six months later it has fourteen actions, a constructor with eleven injected services, and actions that validate input, query the database, map entities, send email, write audit records, and return a view — each in a hundred lines. Nobody decided to build it that way. It accreted.
This article explains why it happens, why it is an architectural problem rather than a code-style complaint, and what to do about it with CQRS — honestly including where MediatR helps and where it does not.
How a controller becomes bloated
Bloat is rarely introduced; it accumulates through individually reasonable decisions:
- "I just need the DbContext here." The first direct database call in a controller is usually a shortcut. It works, it ships, and it sets the precedent.
- "The mapping is only a few lines." Entity-to-view-model mapping spreads into every action, slightly differently each time.
- "Validation goes where the request is." Rules end up duplicated between the action, the view, and the database.
- "The email can go after SaveChanges." Side effects join the same action, so the action can no longer be tested without a mail server.
- "It is the same logic as the other action — I will copy it." Duplication appears, and the copies drift.
The symptoms are unmistakable:
- The constructor lists services that no single action uses.
- Actions cannot be understood without scrolling; a change to one use case risks another.
- Unit tests need half the application's infrastructure just to execute one action.
- Merge conflicts concentrate in the same few controller files.
- Validation and permissions are implemented inconsistently because there is no single place where a use case is defined.
The root cause: a controller is a routing mechanism
In the Model-View-Controller pattern, the Controller coordinates: it receives a request, selects what should happen, and returns a response. Nothing in the pattern says the controller should own the rules of what happens. But in a typical MVC application, the controller is the only obvious place for "what happens", so it becomes the container for every use case that a URL can reach.
Use cases have a natural shape: they take a specific input, enforce specific rules, touch specific data, and produce a specific result. A controller action is the wrong container for that shape because the container is organized around URLs, not around operations.
Separate write operations from read operations
CQRS — Command Query Responsibility Segregation — is the observation that writes and reads have different requirements and deserve separate models:
- Commands change state: create a todo, assign an owner, delete a record. They validate input, enforce business rules, and return a minimal result — usually an id or a status.
- Queries return data and change nothing. They filter, sort, page, and project into shapes the UI needs.
In practice, this means one class per operation. Instead of a controller action that knows everything about "creating a todo", there is a CreateTodoHandler whose only job is that operation. The controller action shrinks to a few lines that call it.
// Before: the action owns the rules
[HttpPost]
public async Task<IActionResult> Create(CreateTodoRequest request)
{
if (string.IsNullOrWhiteSpace(request.Name))
ModelState.AddModelError("Name", "Todo Name is required");
if (!ModelState.IsValid)
return BadRequest(ModelState);
var entity = new Data.Entities.Todo { Name = request.Name, ... };
_context.Todo.Add(entity);
await _context.SaveChangesAsync();
await _emailSender.SendAsync(...);
return CreatedAtAction(nameof(Detail), new { id = entity.Id }, entity);
}
// After: the action routes, the handler owns the use case
public class CreateTodoHandler
{
public async Task<ApiResponse<CreateTodoResponse>> HandleAsync(
CreateTodoRequest request, CancellationToken ct = default)
{
// validate, build the entity graph, save, return a typed result
}
}
The controller keeps its legitimate responsibilities: route the request, bind the DTO, choose the response. The rules move to a class that can be tested with a database context and nothing else.
Direct handlers, or a dispatcher such as MediatR
Once use cases live in handler classes, something must call them. There are two legitimate answers.
Direct invocation. The controller or endpoint constructor-injects the handler and calls HandleAsync. No framework, no request/response wrappers, no indirection. In a Minimal API endpoint it looks like this:
group.MapPost("/", async (CreateTodoRequest request,
CreateTodoHandler handler, CancellationToken ct) =>
{
var result = await handler.HandleAsync(request, ct);
return result.Success
? Results.Created($"/api/todo/{result.Data?.Id}", result)
: Results.BadRequest(result);
});
Dispatching through MediatR. The controller resolves an IMediator and sends the command. The handler implements IRequestHandler. You pay for an extra dependency and one layer of indirection, and you gain pipeline behaviors: validation, logging, transactions, and authorization applied uniformly to every command that flows through the dispatcher.
Two things are worth stating plainly, because the debate around them generates more heat than light:
- MediatR is not the problem, and not the solution. Bloat comes from mixing use cases with routing. CQRS separates them; MediatR is only one way to deliver the messages. If you do not need pipeline behaviors, direct invocation is simpler and easier to debug.
- CQRS does not require MediatR, event sourcing, a message broker, or a separate read database. Separating commands and queries in one process against one database is CQRS in its most common and most useful form.
Minimal APIs are not a verdict against MVC
Minimal API endpoints are sometimes described as the cure for bloated controllers, which misreads both technologies. Microsoft did not introduce Minimal APIs as a solution to controller bloat; they were added for scenarios where a full MVC pipeline is unnecessary — small APIs, focused services, and lightweight endpoints. Choosing between them is an architectural decision, not a correction of a mistake.
In a business application the division of labor is natural:
- MVC controllers route pages, select Razor views, and serve navigation — work they are designed for.
- Minimal API endpoints expose data operations with one route per use case, thin translation between HTTP and handlers, and no view concerns.
- Both surfaces can call the same handler classes, so business rules exist in exactly one place.
That combination — MVC for screens, focused endpoints for operations, CQRS handlers underneath — is the architecture of Part 7's Todo feature and the subject of Part 9.
A practical checklist for trimming a bloated controller
- Inventory every action and write the use case it represents in one sentence. If the sentence needs "and", it is two use cases.
- Create a query handler for each read; keep projections explicit.
- Create a command handler for each write; move validation, mapping, and side effects into it.
- Register handlers in DI (or wire them to MediatR) and reduce each action to routing plus a handler call.
- Add an endpoint per operation for the API surface; keep the controller for page navigation only.
- Move each handler under test as you extract it; the extraction is finished when the action no longer needs the DbContext.
Pitfalls to avoid
- The god handler. A handler that grows to own half the feature is the old controller with a new name. One operation per handler.
- Anemic pass-throughs. A handler that only calls a repository method adds a layer without value. Handlers should enforce the use case, not forward.
- Dispatcher-first thinking. Adopting MediatR before the use cases are separated leaves you with the same bloat and more indirection.
- Confusing CQRS with scale infrastructure. You do not need separate databases or event sourcing to benefit from separated commands and queries.
Key Takeaways
- Controllers bloat when they are used as use-case containers instead of routing mechanisms.
- CQRS separates writes and reads into focused handler classes; one operation per class.
- Handlers can be invoked directly through DI — MediatR is an optional dispatcher, not a requirement.
- Minimal APIs were not introduced to fix controller bloat; MVC stays for pages, endpoints handle operations.
- Both surfaces can share the same handlers, so business rules live in one place.
FAQ
Is my controller too big?
A useful test: can you describe each action in one sentence without "and"? Does the action need the database context directly? If most actions fail both tests, the controller is carrying use cases that deserve their own classes.
Does CQRS require MediatR?
No. MediatR is a dispatcher that some teams use to route commands and queries to handlers. CQRS is the separation itself, and direct handler invocation works without any library.
Do Minimal APIs replace MVC controllers?
No. They solve different problems. MVC renders pages; Minimal APIs expose focused operations. A business application can use both in one project.
Is CQRS overkill for a small application?
Full CQRS infrastructure can be. Plain handler classes per use case, however, are cheap, and they keep the code readable as the application grows into something less small.
