Validation keeps the junk out of your application. The question is where you run it. If you validate inside every command and query handler, you end up copying the same checks everywhere, and your handlers fill up with guard clauses instead of business logic. There is a cleaner way: validate once, centrally, inside the MediatR pipeline - before the request ever reaches a handler.
In this guide I will wire up validation in the MediatR pipeline using FluentValidation in ASP.NET Core on .NET 10, and catch the failures with IExceptionHandler so the client always gets a clean Problem Details response. This is the exact pattern I use on CQRS projects. Everything runs out of the box with zero database setup. Let’s get into it.
Thinking of leaving MediatR? Read the exit ramp.
MediatR went commercial in July 2025. I built a custom CQRS dispatcher in .NET 10 that benchmarks 4.4x faster than MediatR 12 in about 100 lines of code.
TL;DR - Validation in the MediatR Pipeline
To validate MediatR requests centrally in ASP.NET Core .NET 10: write your rules as FluentValidation validators, then add a ValidationBehavior<TRequest, TResponse> that implements IPipelineBehavior. It runs every validator for the incoming request before the handler executes, and throws a ValidationException if any rule fails. Register it with cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)), then catch the exception with an IExceptionHandler and return a ProblemDetails response. Your handlers stay free of validation code.
This builds on three earlier guides - CQRS with MediatR, FluentValidation, and global exception handling. You do not have to read them first, but they add helpful context.
What Is a MediatR Pipeline Behavior?
When you send a command or query through MediatR, it does not go straight to the handler. It passes through a pipeline first. A pipeline behavior is a piece of code that wraps every request, so it can run logic before and after the handler - a lot like middleware does for HTTP requests.
That makes behaviors the perfect place for cross-cutting concerns: logging, performance timing, transactions, and - the topic here - validation. Instead of repeating the same checks in every handler, you write the logic once in a behavior, and it applies to every request that flows through.
Why Validate in the Pipeline Instead of the Handler?
You could validate inside each handler. But that approach has two problems:
- It does not scale. Every new handler needs the same validation wiring. Miss it in one place and bad data slips through.
- It mixes concerns. Your handler should answer “create this product,” not “is this product valid?” Validation is a separate job.
Putting validation in the pipeline fixes both:
- Your entities and handlers carry no validation code. Rules live in dedicated FluentValidation validators.
- A request is validated before it reaches the handler. By the time your handler runs, the data is already known to be good.
A Note on MediatR Licensing
Before the code, one thing you should know. MediatR is now a commercially licensed library. Starting from version 13.0, it moved from the Apache license to a dual commercial/open-source model under Lucky Penny Software. It remains free for open-source projects, individuals, non-profits, and companies under $5M gross annual revenue - you just register for a free license key at mediatr.io. Larger commercial use needs a paid license. A missing key does not break your app; it only logs a warning at startup.
The good news: this pattern is not tied to MediatR. The pipeline-behavior idea works the same with a source-generated alternative like Mediator or a custom dispatcher you write yourself. I will use MediatR 14.1 here because it is the most documented option and the concepts transfer directly.
Setting Up the Project
I am starting from a small CQRS Web API on .NET 10 - a Product feature with a create command and a list query, using MediatR and the EF Core InMemory provider so it runs with no database setup. If you want the CQRS starting point explained, see my CQRS with MediatR guide.
You need MediatR plus the two FluentValidation packages:
dotnet add package MediatR --version 14.1.0dotnet add package FluentValidation --version 12.0.0dotnet add package FluentValidation.DependencyInjectionExtensions --version 12.0.0Step 1: A Logging Behavior (To See the Pipeline Work)
Before validation, here is the simplest possible behavior so you can see how the pipeline fits together. It logs the request on the way in and the response on the way out, with a correlation id linking the two.
public class RequestResponseLoggingBehavior<TRequest, TResponse>( ILogger<RequestResponseLoggingBehavior<TRequest, TResponse>> logger) : IPipelineBehavior<TRequest, TResponse> where TRequest : class{ public async Task<TResponse> Handle( TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken) { var correlationId = Guid.NewGuid();
logger.LogInformation("Handling {RequestName} [{CorrelationId}]: {@Request}", typeof(TRequest).Name, correlationId, request);
// next() passes control to the next behavior, or to the handler itself. var response = await next(cancellationToken);
logger.LogInformation("Handled {RequestName} [{CorrelationId}]", typeof(TRequest).Name, correlationId);
return response; }}The shape is always the same: implement IPipelineBehavior<TRequest, TResponse>, do your work, and call await next(cancellationToken) to continue down the pipeline. Register it in Program.cs:
builder.Services.AddMediatR(cfg =>{ cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()); cfg.AddOpenBehavior(typeof(RequestResponseLoggingBehavior<,>));});Step 2: Write the Validator
Now the validation rules. With FluentValidation, rules live in their own class, separate from the command. Here is a validator for the create-product command:
public class CreateProductCommandValidator : AbstractValidator<CreateProductCommand>{ public CreateProductCommandValidator() { RuleFor(p => p.Name) .NotEmpty() .MinimumLength(4);
RuleFor(p => p.Price) .GreaterThan(0); }}The name must be present and at least 4 characters, and the price must be greater than zero. You can have one of these per command or query, and they stay neatly beside the feature they belong to.
Register all your validators in one line - this scans the project and picks up every AbstractValidator:
builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());Step 3: The Validation Behavior
This is the heart of the article. The behavior pulls in every validator registered for the incoming request, runs them, and throws if anything fails:
public class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators) : IPipelineBehavior<TRequest, TResponse> where TRequest : class{ public async Task<TResponse> Handle( TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken) { // Only validate if at least one validator exists for this request type. if (validators.Any()) { 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(cancellationToken); }}Walking through it:
- MediatR injects every
IValidator<TRequest>for the request type. If none exist (a query with no rules, say), we skip straight to the handler. - We run all validators and collect every failure into one list.
- If there are failures, we throw a single
FluentValidation.ValidationExceptioncarrying them all. The handler never runs. - If everything passes,
await next(cancellationToken)continues to the handler.
Register it after the logging behavior:
builder.Services.AddMediatR(cfg =>{ cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()); cfg.AddOpenBehavior(typeof(RequestResponseLoggingBehavior<,>)); cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));});Order matters. Behaviors run in the order you register them, like layers of an onion. Logging is registered first, so it wraps validation. That means a request is logged on the way in, then validated. If validation fails, the exception is thrown and the response is never logged - the flow stops right there. That is usually what you want.
Notice what is not in the handler:
public class CreateProductCommandHandler(AppDbContext context) : IRequestHandler<CreateProductCommand, ProductDto>{ public async Task<ProductDto> Handle(CreateProductCommand request, CancellationToken cancellationToken) { var product = new Product(request.Name, request.Description, request.Price);
context.Products.Add(product); await context.SaveChangesAsync(cancellationToken);
return new ProductDto(product.Id, product.Name, product.Description, product.Price); }}No validation. No guard clauses. By the time the handler runs, the data is already valid.
Step 4: Turn the Exception Into a Clean Response
Right now a validation failure throws an exception that would bubble up as a 500 error. That is not what we want - validation errors are the client’s fault, so they should return a 400 with a clear message. I handle that with IExceptionHandler, the modern way to handle exceptions in ASP.NET Core:
public class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger) : IExceptionHandler{ public async ValueTask<bool> TryHandleAsync( HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { if (exception is not ValidationException validationException) { return false; // not ours to handle }
logger.LogWarning("Validation failed: {Message}", validationException.Message);
var problemDetails = new ProblemDetails { Status = StatusCodes.Status400BadRequest, Title = "One or more validation errors occurred.", Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1", Instance = httpContext.Request.Path };
problemDetails.Extensions["errors"] = validationException.Errors .Select(error => error.ErrorMessage) .ToList();
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken); return true; }}The key line is the early return false: if the exception is anything other than a ValidationException, this handler steps aside and lets the default pipeline deal with it. When it is a validation failure, we build a Problem Details response, attach the error messages, and return 400.
Register the handler and Problem Details, then add the middleware:
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();Testing It
The sample seeds a couple of products and exposes POST /products and GET /products. Run it and open the Scalar UI at /scalar/v1, or use the included requests.http. Here is the run I did while writing this.
Send a product that breaks both rules - a 2-character name and a price of zero:
{ "name": "ab", "description": "", "price": 0}The pipeline rejects it before the handler runs, and the exception handler returns 400 with this body:
{ "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1", "title": "One or more validation errors occurred.", "status": 400, "instance": "/products", "errors": [ "The length of 'Name' must be at least 4 characters. You entered 2 characters.", "'Price' must be greater than '0'." ]}Send a valid product and you get 201 Created with the new record. The handler only ever runs for good data, and you never wrote a single if to check it.
When Should You Use This Pattern?
This is the pattern I reach for on most CQRS projects, but it is not the answer for every app. Here is my honest take:
| Your situation | Use pipeline validation? |
|---|---|
| You already use MediatR / CQRS with commands and queries | Yes - this is the natural home for validation |
| You have many handlers with repeated validation | Yes - it removes all the duplication |
| Simple CRUD app, no MediatR | No - use FluentValidation endpoint filters instead |
| A tiny API with one or two endpoints | Probably not - the wiring is more than the payoff |
My take: if you are already on MediatR, adding a validation behavior is close to free and pays off the moment you have more than a handful of handlers. If you are not on MediatR, do not adopt it just for this - validate with endpoint filters and keep things simple. The goal is clean handlers, not patterns for their own sake.
Troubleshooting
Validation never runs? Check two registrations: AddValidatorsFromAssembly (so the validators are in DI) and AddOpenBehavior(typeof(ValidationBehavior<,>)) (so the behavior is in the pipeline). Missing either one means the behavior silently does nothing.
Validation runs but you still get a 500, not a 400? Your IExceptionHandler is not catching the ValidationException. Make sure you registered it with AddExceptionHandler<GlobalExceptionHandler>(), added AddProblemDetails(), and called app.UseExceptionHandler().
Behaviors run in the wrong order? They execute in registration order. Whatever you register first wraps the rest. Register logging before validation if you want the request logged even when validation fails.
A MediatR license warning at startup? That is expected since version 13. Register a free key at mediatr.io to silence it. It does not affect how the app runs.
Validators not found? AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()) only scans the current project. If your validators live in another project, pass that assembly instead.
Key Takeaways
- A MediatR pipeline behavior wraps every request, making it the right place for cross-cutting concerns like validation.
- Validating in the pipeline keeps handlers free of guard clauses - rules live in FluentValidation validators.
- The
ValidationBehaviorruns all validators and throws oneValidationExceptionif any rule fails, before the handler runs. - Catch that exception with
IExceptionHandlerand return a400Problem Details response. - Behavior order follows registration order - register logging before validation.
- MediatR is commercially licensed since v13 but free under $5M revenue; the pattern works with any mediator alternative.
What is a MediatR pipeline behavior?
A pipeline behavior is a class that implements IPipelineBehavior and wraps every MediatR request. It runs code before and after the handler, similar to ASP.NET Core middleware. It is the standard place for cross-cutting concerns like validation, logging, and performance timing in a CQRS application.
How do I validate MediatR requests with FluentValidation?
Write your rules as FluentValidation validators, register them with AddValidatorsFromAssembly, then add a ValidationBehavior that implements IPipelineBehavior. The behavior injects every IValidator for the request, runs them before the handler, and throws a ValidationException if any rule fails. Register the behavior with AddOpenBehavior(typeof(ValidationBehavior<,>)).
Why validate in the pipeline instead of inside the handler?
Validating in the pipeline keeps your handlers focused on business logic instead of guard clauses, and avoids repeating the same validation wiring in every handler. The request is validated once, centrally, before it ever reaches a handler, so by the time the handler runs the data is already known to be valid.
How do I return a 400 Problem Details response for validation errors?
Implement IExceptionHandler. In TryHandleAsync, check if the exception is a FluentValidation ValidationException; if so, build a ProblemDetails object with status 400, attach the error messages in the extensions, and write it to the response. Return false for any other exception so the default handler takes over. Register it with AddExceptionHandler and AddProblemDetails.
Does the order of MediatR behaviors matter?
Yes. Behaviors run in the order they are registered, wrapping each other like layers of an onion. If you register logging before validation, the request is logged first and then validated, so a validation failure stops the flow after logging the request but before logging a response. Register them in the order you want them to execute.
Is MediatR still free to use?
MediatR moved to a commercial license under Lucky Penny Software from version 13.0. It is free for open-source projects, individuals, non-profits, and companies under $5M gross annual revenue, with a free license key from mediatr.io. Larger commercial use requires a paid license. A missing key only logs a startup warning and does not break the app.
Can I use this validation pattern without MediatR?
Yes. The pipeline-behavior approach is a concept, not a MediatR-only feature. You can apply the same idea with a source-generated alternative like Mediator, or with a custom dispatcher you write yourself. The validator and exception-handler code stay exactly the same.
Summary
You now have central request validation in the MediatR pipeline on .NET 10: FluentValidation validators hold the rules, a ValidationBehavior runs them before every handler, and an IExceptionHandler turns failures into a clean Problem Details response - all backed by code that builds and runs with zero database setup. Your handlers stay focused on what they are actually for.
The full source code is in the GitHub repository. This article is part of my .NET Web API Zero to Hero course, and builds on CQRS with MediatR, FluentValidation, and global exception handling.
If MediatR’s license change has you reconsidering, I also wrote about building CQRS without MediatR - the same validation pattern works there too.
Happy Coding :)




What's your take?
Push back, share a war story, or ask the obvious question someone else is wondering. I read every comment.