Skip to main content
SEMastery

CQRS Validation with MediatR Pipeline and FluentValidation in .NET

Learn centralized CQRS validation in .NET using a MediatR pipeline behavior and FluentValidation. Simple words, clear diagrams, and real C# code.

12 min readUpdated April 5, 2026

A guard at the school gate

Think about the main gate of your school.

Before any student walks in, a guard checks them. Is the uniform correct? Is the ID card there? Are the shoes black? Only after these checks does the guard say, "Okay, you may go in."

The guard does the same checks for every student. The guard does not let the class teacher do this job inside each classroom. That would be slow and messy. One guard, one gate, one set of rules for everybody.

In a .NET app, our commands are like students walking in. We want one friendly guard who checks every command before it reaches the part that does the real work. That guard is a MediatR pipeline behavior, and the rules it checks come from FluentValidation.

Let us build this guard together.

What problem are we solving?

In CQRS, we send commands (which change data) and queries (which read data) as small message objects. A tool called MediatR carries each message to the correct handler.

Without a guard, every handler has to check its own input:

public async Task<int> Handle(CreateStudentCommand command, CancellationToken ct)
{
    // Validation mixed into business logic. Repeated everywhere.
    if (string.IsNullOrWhiteSpace(command.Name))
        throw new Exception("Name is required.");
 
    if (command.Age < 5)
        throw new Exception("Age must be at least 5.");
 
    // ...the real work finally starts here
    var student = new Student(command.Name, command.Age);
    _db.Students.Add(student);
    await _db.SaveChangesAsync(ct);
    return student.Id;
}

This is repeated in every handler. It is easy to forget. It is hard to read. And the validation rules are tangled with the business logic.

We want something better. We want the validation to live in one place and run automatically for every command. That is exactly what a pipeline behavior gives us.

Validation tangled inside every handler versus one central guard

How the MediatR pipeline works

When you call mediator.Send(command), the message does not jump straight to the handler. First it travels through a pipeline of behaviors. Each behavior can do work before and after the next step.

Think of it like a row of gates. The message passes through each gate in order. The last gate is the handler that does the real job.

The request pipeline

Send
Logging
Validation
Handler
Response

Steps

1

Send

App calls mediator.Send(command)

2

Logging

Behavior records the request

3

Validation

Behavior checks the rules

4

Handler

Real work runs if valid

5

Response

Result travels back out

A command passes through behaviors before reaching its handler

Each behavior implements one interface: IPipelineBehavior<TRequest, TResponse>. Inside its Handle method, you get the request, and a next delegate. Calling next() means "let the message continue to the next gate." Not calling it means "stop here."

This next design is what lets us block a bad command. If validation fails, we simply do not call next(). The handler never runs.

A single behavior wraps the next step in the pipeline

Meet FluentValidation

FluentValidation is a small, free library that lets you write validation rules in a clean, readable way. You make a class that inherits from AbstractValidator<T> and list your rules.

Here is a validator for a command that creates a student:

using FluentValidation;
 
public sealed class CreateStudentCommandValidator
    : AbstractValidator<CreateStudentCommand>
{
    public CreateStudentCommandValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty().WithMessage("Name is required.")
            .MaximumLength(100);
 
        RuleFor(x => x.Age)
            .InclusiveBetween(5, 120)
            .WithMessage("Age must be between 5 and 120.");
 
        RuleFor(x => x.Email)
            .NotEmpty()
            .EmailAddress().WithMessage("Please enter a valid email.");
    }
}

Read it out loud. "Rule for Name: not empty, maximum length 100." It almost reads like plain English. That is the whole point. The rules are easy to write and easy to read later.

The nice thing is that this validator knows nothing about MediatR. It is just a class of rules. We will connect it to the pipeline next.

Building the validation behavior

Now we write the guard. This behavior will:

  1. Find every validator registered for the current command.
  2. Run all of them.
  3. Collect any errors.
  4. If there are errors, throw a ValidationException.
  5. If there are no errors, call next() so the handler runs.
using FluentValidation;
using MediatR;
 
public sealed class ValidationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : notnull
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;
 
    public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
        => _validators = validators;
 
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        // No validators for this request? Just continue.
        if (!_validators.Any())
            return await next();
 
        var context = new ValidationContext<TRequest>(request);
 
        var results = await Task.WhenAll(
            _validators.Select(v => v.ValidateAsync(context, cancellationToken)));
 
        var failures = results
            .SelectMany(r => r.Errors)
            .Where(f => f is not null)
            .ToList();
 
        if (failures.Count != 0)
            throw new ValidationException(failures);
 
        return await next();
    }
}

Notice a few kind details:

  • We inject IEnumerable<IValidator<TRequest>>. The container hands us all validators for this command type. Most commands have one, but you can have several.
  • If there are no validators, we skip straight to next(). Queries that need no validation are not slowed down.
  • We run validators with ValidateAsync, so async rules (like checking a database) work too.

Wiring it all up

Now we register everything with dependency injection. This is the glue that makes the magic automatic.

var builder = WebApplication.CreateBuilder(args);
 
// Register MediatR and our behavior.
builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
    cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
});
 
// Find and register all FluentValidation validators in this assembly.
builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);
 
var app = builder.Build();

AddOpenBehavior(typeof(ValidationBehavior<,>)) plugs our guard into the pipeline for every request. The <,> means it works for any request and response type.

AddValidatorsFromAssembly(...) scans your project and registers every AbstractValidator it finds. So when you add a new validator class later, it is picked up automatically. You do not edit any wiring.

This is the part students love. You write a validator class, and it just works. No extra steps.

Adding a new rule later

Write validator
Scanner finds it
Pipeline uses it

Steps

1

Write validator

Create a new AbstractValidator class

2

Scanner finds it

AddValidatorsFromAssembly registers it

3

Pipeline uses it

Behavior runs it automatically

Once the pipeline is set up, new validators need zero wiring

Turning failures into a friendly HTTP response

When validation fails, our behavior throws a ValidationException. We do not want that to crash the app with a scary 500 error. We want a clean 400 Bad Request with a clear message for the user.

In ASP.NET Core, the easiest way is a small exception handler or middleware that catches the exception and shapes a nice response.

app.UseExceptionHandler(handler =>
{
    handler.Run(async context =>
    {
        var feature = context.Features.Get<IExceptionHandlerFeature>();
 
        if (feature?.Error is ValidationException validationException)
        {
            var errors = validationException.Errors
                .GroupBy(e => e.PropertyName)
                .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
 
            context.Response.StatusCode = StatusCodes.Status400BadRequest;
            await context.Response.WriteAsJsonAsync(new
            {
                title = "One or more validation errors occurred.",
                status = 400,
                errors
            });
            return;
        }
 
        context.Response.StatusCode = StatusCodes.Status500InternalServerError;
    });
});

Now a request with a bad age comes back as a tidy JSON object listing exactly what was wrong. The user knows how to fix it.

The full journey of a bad command, from request to friendly error

Throw an exception, or return a Result?

There are two popular styles for reporting failures. Both are fine. Pick one and stay consistent across your app.

StyleHow it worksGood forWatch out for
Throw ValidationExceptionBehavior throws, middleware catches itSimple apps, quick setupExceptions for expected errors can feel heavy
Return a Result objectBehavior returns a failure result, no throwFunctional style, no exceptions for normal flowNeeds your response type to be a Result<T>

The throwing style is shown above and is the most common. If you already use the Result pattern in your project, you can change the behavior to build a failed Result instead of throwing. The idea is the same: stop the bad command before the handler runs.

Here is a small sketch of the Result style for comparison:

if (failures.Count != 0)
{
    // Build a typed failure Result instead of throwing.
    var errors = failures.Select(f => f.ErrorMessage).ToArray();
    return (TResponse)ResultFactory.Failure(errors);
}

This needs a bit more plumbing so the behavior knows how to build a Result<T> of the right type, but it avoids exceptions for everyday validation mistakes.

Where this fits in CQRS

Let us zoom out and see the whole shape. In CQRS, commands change data and queries read data. Validation usually matters most for commands, because they write new information. But you can validate queries too, for example to check that a page number is not negative.

Message typeWhat it doesDo we validate?
CommandChanges data (create, update, delete)Yes, almost always
QueryReads data onlySometimes, for safe inputs
NotificationTells other parts something happenedRarely

Because our behavior is registered for all requests, both commands and queries pass through it. If a query has no validator, the behavior skips straight to the handler. So you only pay the cost where you actually wrote rules.

Commands and queries both flow through the same validation gate

Common mistakes and how to avoid them

Forgetting to register the behavior. If you write a validator but never add AddOpenBehavior, nothing runs. The validator just sits there unused. Always register both the behavior and the validators.

Order of behaviors matters. Behaviors run in the order you add them. Usually you want logging first, then validation, then maybe a transaction behavior. Put validation before anything that touches the database, so you never start a transaction for a bad request.

Validating things the validator cannot know. FluentValidation is great for shape checks: not empty, correct length, valid email. But deep business rules, like "this seat is already taken," often need the database and belong closer to the handler or domain. Use the validator for input shape, and the handler for true business rules.

Putting heavy work in a validator. A rule that calls a slow service on every request will slow your whole app. Keep validators light. If you must check the database, make it async and keep it simple.

Async all the way. Use ValidateAsync, not Validate, inside the behavior. This keeps async database rules working and avoids blocking threads.

A note on licensing

MediatR moved to a commercial license in July 2025 under Lucky Penny Software (the same move happened with AutoMapper). New versions use a dual-license model: a reciprocal open license and a paid commercial license.

There is a free Community edition for small companies (under a revenue limit), non-profits, schools, learning, and non-production use. Bigger companies usually need a paid license for production.

The good news: FluentValidation stays free and open source. And the pipeline idea itself is not owned by anyone. If you ever want to drop MediatR, you can hand-write a tiny dispatcher with the same behavior idea. The pattern in this post outlives any one library.

Putting it together: the full flow

Here is the whole story in one picture. A request comes in, passes the guard, and either gets handled or gets a clear error.

End to end request flow

Client
MediatR
Validation
Handler
Database
Response

Steps

1

Client

Calls Send with a command

2

MediatR

Starts the pipeline

3

Validation

Runs FluentValidation rules

4

Handler

Does the real work if valid

5

Database

Saves the change

6

Response

Returns success or 400 errors

From the client call to the saved record or the helpful error

By keeping validation in one behavior, your handlers stay short and clear. Each handler only worries about its real job. The guard at the gate handles the checking, every single time, for every single command. You write a rule once, and you never forget to use it.

Quick recap

  • CQRS splits messages into commands (write) and queries (read). MediatR carries them to handlers.
  • A pipeline behavior wraps every request. It can run code before and after the handler. It is the perfect spot for cross-cutting jobs like validation.
  • FluentValidation lets you write clean, readable rules in AbstractValidator<T> classes that know nothing about MediatR.
  • The ValidationBehavior finds all validators for a request, runs them, and stops the request if any rule fails.
  • Register the behavior with AddOpenBehavior and the validators with AddValidatorsFromAssembly. New validators are then picked up automatically.
  • Catch the ValidationException and turn it into a friendly 400 Bad Request response.
  • You can throw or return a Result on failure. Pick one style and stay consistent.
  • Keep validators light. Use them for input shape, and keep deep business rules near the handler.
  • MediatR is now commercially licensed (free Community edition exists). FluentValidation stays free and open source.

References and further reading

Related Patterns