🛠 Series: ERP RFQ ModulePart 2 of 8

"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