"A controller that makes database calls is not a controller — it is a disguised service layer with an HTTP address."
In this post we install MediatR and implement CQRS (Command Query Responsibility Segregation) in the Application layer. Every business operation becomes either a Command (changes state) or a Query (reads state). Controllers send the command and return the result — zero business logic in the API layer.
Part of the Green Concrete ERP project. View full project page →
Command vs Query — The Core Distinction
A Command changes state and returns a result (success/failure + data). A Query reads state and returns data without changing anything. Keeping them separate means you can scale reads and writes independently, cache queries aggressively, and add audit logging to commands only.
// LoginCommand.cs — Command (changes session state, returns JWT)
public record LoginCommand(string Username, string Password)
: IRequest<LoginResult>;
// GetQuotationsQuery.cs — Query (reads only, no state change)
public record GetQuotationsQuery(string? Status, int PageSize)
: IRequest<PagedResult<QuotationDto>>;LoginHandler — Complete Implementation
public class LoginHandler : IRequestHandler<LoginCommand, LoginResult>
{
private readonly IUserRepository _users;
private readonly IJwtService _jwt;
public LoginHandler(IUserRepository users, IJwtService jwt)
=> (_users, _jwt) = (users, jwt);
public async Task<LoginResult> Handle(LoginCommand cmd, CancellationToken ct)
{
var user = await _users.FindByUsernameAsync(cmd.Username, ct);
if (user is null || !BCrypt.Verify(cmd.Password, user.PasswordHash))
return LoginResult.Failed("Invalid credentials");
var token = _jwt.GenerateToken(user);
return LoginResult.Success(token, user.Role);
}
}
// AuthController.cs — the controller (3 lines of logic)
[HttpPost("login")]
public async Task<IActionResult> Login(LoginCommand cmd)
=> Ok(await _mediator.Send(cmd));The controller has zero knowledge of users, BCrypt, or JWT generation. It just routes the command. Adding a new endpoint = write a new Command + Handler. No controller changes needed.
MediatR Pipeline Behaviours
Pipeline behaviours wrap every command/query, similar to middleware. Add logging, validation, and performance monitoring in one place:
// ValidationBehaviour.cs — runs before every handler
public class ValidationBehaviour<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
{
public async Task<TResponse> Handle(TRequest request,
RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
var failures = _validators
.SelectMany(v => v.Validate(request).Errors)
.Where(f => f != null).ToList();
if (failures.Any())
throw new ValidationException(failures);
return await next();
}
}🎯 Quick Check
Q1: What is the difference between a Command and a Query in CQRS?
Show Answer
A Command changes state (creates, updates, deletes data) and returns a result indicating success or failure. A Query reads state without changing it and returns data. Keeping them separate enables independent scaling, caching of queries, and audit logging of commands only.
Q2: What does a MediatR pipeline behaviour do?
Show Answer
A pipeline behaviour wraps every command or query handler, similar to middleware in ASP.NET Core. It runs code before and/or after the handler. Common uses: validation (FluentValidation), logging, performance monitoring, retry logic, and transaction management.
Q3: Why can the same LoginHandler be called from HTTP, gRPC, and a background job?
Show Answer
Because the handler is decoupled from the transport layer. It receives a LoginCommand record and returns a LoginResult — it has no dependency on HttpContext, ControllerBase, or any HTTP-specific type. Any caller that can send a MediatR message can use it.
Key Takeaways
- Commands change state, Queries read state — keeping them separate enables independent scaling and caching
- MediatR decouples controllers from business logic — controllers become 3-line route handlers
- Pipeline behaviours add cross-cutting concerns (validation, logging) without modifying handlers
- Every new feature = write a new Command/Query + Handler — no controller changes needed
- Handlers are testable in isolation with mocked interfaces — no HTTP context or database required