Say “CQRS” and most people picture separate read and write databases, event sourcing, message queues. That big picture exists, but the part that earns its keep in an enterprise API every day is much simpler: every request has a name, a single owner and a single entry point. This post describes the MediatR-based CQRS layout we use in Supply Management on .NET: why we chose it, how we set it up, and where we stopped.
The problem: where does a business rule live?
In a classic layered API, “approve the request” lives in three places at once: the controller takes the parameter and checks a little, the service class does the real work but calls other services too, the repository writes the data. Six months later the same rule is written again in a second controller for the mobile endpoint, and the two copies quietly drift apart. As approval conditions multiply, the answer to “can an approved request be approved again?” depends on where in the code you look.
The core distinction of CQRS is splitting requests into commands (change state) and queries (only read). MediatR is a small intermediary that carries the request object to the class that handles it. Together they produce this layout: every business rule has one command, every command has one handler.
Commands: the name and the contract of a request
Commands are defined as records and carry only data. A slice of the request flow:
public record CreateMaterialRequestCommand(MaterialRequestInputModel Model)
: IRequest<MaterialRequestOutputModel>;
public record ApproveMaterialRequestCommand(int Id, string? Note)
: IRequest<MaterialRequestOutputModel>;
public record SubmitOfferCommand(int Id, int SupplierId, decimal? Amount,
string? Currency, string? Note, int TechnicalApproverId)
: IRequest<MaterialRequestOutputModel>;
public record ReceiveMaterialRequestCommand(int Id, bool Incomplete = false, string? Note = null)
: IRequest<MaterialRequestOutputModel>;
This file is the application’s business vocabulary. A new developer sees “what can this system do” here without touring the controllers. A command name contains a verb, its parameters are limited to what is really needed, its return type is explicit. The same holds for queries: GetMyRequestsQuery, GetPendingApprovalsQuery.
The controller: a one-line door
The controller’s only job is to turn HTTP into a command and wrap the result in the standard envelope:
public abstract class ApiControllerBase(ISender sender) : ControllerBase
{
protected async Task<ApiResponse<TResponse>> Execute<TResponse>(IRequest<TResponse> request)
=> ApiResponse<TResponse>.Ok(await sender.Send(request));
}
[HttpPost("{id:int}/approve")]
[RequirePermission(Permissions.RequestApprove)]
public Task<ApiResponse<MaterialRequestOutputModel>> Approve(int id, [FromBody] DecisionInputModel m)
=> Execute(new ApproveMaterialRequestCommand(id, m.Note));
Authorisation in an attribute, the business rule in the handler, the error format in a shared middleware. Seeing an if in a controller is a sign that something was written in the wrong place. The concrete payoff of this layout is that the same command can be sent from elsewhere: the workflow engine sends a decision made from the inbox as an ApproveMaterialRequestCommand, and exactly the same logic runs as from the request screen.
Handlers: one class per aggregate
Here we depart from common practice. Most MediatR examples open a separate handler class per command; in a module with forty commands that is forty files, each with the same dependencies and the same helper methods. We use one handler class per aggregate:
public class MaterialRequestCommandHandler(
SupplyManagementDbContext db, ICurrentUser currentUser,
WorkflowEngine workflow, INotificationService notifications) :
IRequestHandler<CreateMaterialRequestCommand, MaterialRequestOutputModel>,
IRequestHandler<ApproveMaterialRequestCommand, MaterialRequestOutputModel>,
IRequestHandler<RejectMaterialRequestCommand, MaterialRequestOutputModel>,
IRequestHandler<SubmitOfferCommand, MaterialRequestOutputModel>
// ... every command of the request
{
public async Task<MaterialRequestOutputModel> Handle(
ApproveMaterialRequestCommand cmd, CancellationToken ct)
{
var entity = await LoadAsync(cmd.Id, ct);
EnsureStatus(entity, RequestStatus.PendingApproval);
SetDecision(entity, cmd.Note);
entity.Status = RequestStatus.Approved;
await workflow.AdvanceAsync(WorkflowEntityTypes.MaterialRequest, entity.Id,
WorkflowActions.Approve, cmd.Note, ct);
await db.SaveChangesAsync(ct);
return Map(entity);
}
private static void EnsureStatus(MaterialRequest entity, params RequestStatus[] expected)
{
if (!expected.Contains(entity.Status))
throw new BusinessException(ErrorCodes.InvalidStatusTransition, entity.Status.DisplayText);
}
}
The reasoning is simple: approving, rejecting, submitting an offer for and receiving a request are parts of the same entity’s same lifecycle. When they sit in one file, helpers such as EnsureStatus, SetDecision and Map stay single copies and the status transition rules read as one piece. Queries live in a separate class (MaterialRequestQueryHandler); the read side does not carry the write side’s dependencies and applies read optimisations such as AsNoTracking freely.
The length of this class is not a problem but a signal: about twelve hundred lines, because the flow has twelve commands. The day we want to split it, the command groups will show the natural seam.
The pipeline: validate once, for every command
MediatR’s most valuable feature is IPipelineBehavior: a middle layer every request passes through before reaching its handler. We have a single behaviour, and it wires up FluentValidation:
public class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
: IPipelineBehavior<TRequest, TResponse> where TRequest : notnull
{
public async Task<TResponse> Handle(TRequest request,
RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
foreach (var v in validators)
{
var result = await v.ValidateAsync(request, ct);
if (!result.IsValid)
throw new BusinessException(ErrorCodes.ValidationFailed,
string.Join(" | ", result.Errors.Select(e => e.ErrorMessage)));
}
return await next();
}
}
// Program.cs
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);
Writing a validator for a command is enough to put it in the pipeline; the handler knows it works with validated data and writes no input checks. Logging, authorisation, transactions and performance measurement can be added with the same pattern. We deliberately kept one: every behaviour enters the path of every request, and the longer the pipeline, the blurrier the answer to “why is this request slow”.
The error model: exception, envelope, code
Handlers throw a BusinessException when a business rule is violated; the error code is a constant, the message is shown to the user. A single middleware catches the exception and writes it into the envelope every response carries: { success, data, errorCode, message }. The client, web or mobile, speaks one contract and can translate by error code. An “invalid status transition” error returns the same code whichever handler raised it.
Where to stop
The temptation of CQRS is the invitation to split every layer in two. Where we stopped:
- One database. Read and write models use the same EF Core context; queries are lightened only with projections and
AsNoTracking. A separate read store comes up when reporting load really strains the production tables, not before. - No event sourcing. The audit trail is written to its own table; state lives on the entity. Building an event store before the need to replay history exists is buying complexity early.
- Notifications, sparingly. We use MediatR’s
INotificationonly for genuinely independent side effects (e-mail, push). The workflow itself advances through an engine called explicitly inside the handler; the answer to “who triggered this” is readable from the code. - A mediator, not an architecture. MediatR is a library; handlers can be called directly too. Remove it tomorrow and the commands, handlers and validators stay exactly as they are; only the
Sendcall changes.
MediatR 13 and licensing
In 2025 MediatR became a commercial product: 13.0 and later ask for a licence key. A free Community licence exists for companies with under $5 million in annual gross revenue that have raised less than $10 million in outside capital; government bodies and universities are excluded. Paid plans start at $799 per year for up to ten developers. Without a key the library keeps working and only logs a warning.
Supply Management is on 12.x, the last Apache-licensed generation. For a small team the Community licence is sufficient; when delivering to an enterprise customer, the licence status should be part of the delivery scope. That is one more reason to keep the dependency light: a single interface wrapping the ISender.Send call makes swapping the library a one-day job if ever needed.
Summary
What this layout gives us is not a technical pattern but a discipline: every business rule has a name, lives in one place, has its validation separate, its authorisation separate, its error standard. Adding a new approval step is one command and one Handle method; adding a new client is one door, without touching any business rule. When the big picture of CQRS is needed, this foundation is already there; when it is not, you have carried nothing extra.