7 Common FluentValidation Mistakes in ASP.NET Core (And How to Fix Them)

FluentValidation is one of those libraries that feels obvious once you're using it — until you hit a production bug that traces back to a subtly misonfigured validator. In years of building ASP.NET Core APIs, I've seen the same validation mistakes surface again and again: a cascade that fires redundant error messages, an async rule that silently never runs, a validator lifetime that corrupts shared state under load.
The complete working code for all the patterns in this article is on Patreon - with edge case coverage and a full test suite wired alongside each validator so you can see exactly where each rule fits.
Understanding these pitfalls in isolation is useful, but seeing them corrected inside a full production API - where validators sit alongside EF Core repositories, DI lifetimes, and integration tests - is what makes the fixes actually stick. Chapter 5 of the Zero to Production course walks through every one of these scenarios inside one running codebase, so the context never gets lost.
FluentValidation mistakes are rarely obvious at first glance. Let's go through the seven that bite teams most often.
Mistake 1: Using CascadeMode.Stop on the Class Instead of the Rule
The most common cascade mistake is setting CascadeMode at the wrong level. Many developers write:
public class CreateProductValidator : AbstractValidator<CreateProductRequest>
{
public CreateProductValidator()
{
ClassLevelCascadeMode = CascadeMode.Stop;
RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
RuleFor(x => x.Price).GreaterThan(0);
}
}
Setting ClassLevelCascadeMode = CascadeMode.Stop tells FluentValidation to stop running rules for subsequent properties once any property fails. That means if Name fails, Price never validates - and the client gets no error message for an invalid price.
What you almost always want is per-rule cascade, not per-class:
RuleFor(x => x.Name)
.Cascade(CascadeMode.Stop)
.NotEmpty()
.MaximumLength(100);
With Cascade(CascadeMode.Stop) on the rule itself, FluentValidation stops adding further errors for Name once it fails, but still runs the Price rule. The client gets a complete error set in a single round-trip.
When class-level Stop is right: only when later rules are semantically meaningless unless earlier ones pass - rare outside of deeply nested objects.
Mistake 2: Registering Validators with the Wrong Lifetime
This one caused a subtle production incident I traced for two hours. The symptom: occasional validation errors appearing for requests that should have passed clean. The root cause: a validator registered as Singleton was holding per-request state.
AddFluentValidationAutoValidation() expects validators to be scoped (or transient). If you manually register them as singletons:
// Wrong - singleton validator with scoped dependencies
services.AddSingleton<IValidator<CreateProductRequest>, CreateProductValidator>();
And the validator injects a scoped dependency (like a repository), you get a captive dependency - the scoped service lives longer than it should, and state bleeds across requests.
The correct registration:
// Correct
services.AddValidatorsFromAssemblyContaining<CreateProductValidator>();
// This defaults to ServiceLifetime.Scoped
AddValidatorsFromAssemblyContaining scans the assembly and registers all validators as scoped by default. That's the right lifetime when validators inject repositories or other scoped services.
Mistake 3: Skipping When() on Conditional Rules
A common pattern in request validation is a field that is only required under certain conditions - for example, a discount code is only required when ApplyDiscount is true. Developers often reach for a custom Must() to handle this:
RuleFor(x => x.DiscountCode)
.NotEmpty()
.Must((request, code) => request.ApplyDiscount ? !string.IsNullOrEmpty(code) : true);
This works but it's fragile - the logic is tangled and the error message is opaque. The idiomatic approach is When():
RuleFor(x => x.DiscountCode)
.NotEmpty()
.When(x => x.ApplyDiscount);
When() is readable, composable, and produces a clean error message. It also interacts correctly with cascade and test helpers.
The inverse (Unless()) works the same way and covers the complement case without requiring a negated predicate.
Mistake 4: Using Must() for Async Database Checks
A rule like "email must not already exist in the database" is one of the first places developers reach for Must():
RuleFor(x => x.Email)
.Must(email => !_userRepository.EmailExistsAsync(email).Result); // wrong
This calls .Result on an async method - which blocks the thread, defeats async I/O, and can cause deadlocks under certain synchronization contexts.
The correct approach is MustAsync():
RuleFor(x => x.Email)
.MustAsync(async (email, cancellationToken) =>
!await _userRepository.EmailExistsAsync(email, cancellationToken))
.WithMessage("This email is already registered.");
MustAsync() accepts a delegate that returns Task<bool> and threads the CancellationToken through correctly. When used with AddFluentValidationAutoValidation(), the framework calls ValidateAsync() automatically, so the async path runs end to end.
One additional note: MustAsync() with cancellation requires the repository method to also accept a CancellationToken. If it does not, pass it and ignore it at the call site - but fix the repository signature while you are there.
Mistake 5: Not Testing Validators in Isolation
Validators are logic. Logic needs tests. But most teams only ever test validators through integration tests against the full HTTP pipeline. That means a broken validator only surfaces when a controller endpoint is hit with the right content type, routing is configured, DI is wired up, and the request models correctly. That is a lot of accidental complexity for what should be a unit test.
FluentValidation ships TestValidate() exactly for this:
[Fact]
public void Name_WhenEmpty_ShouldHaveError()
{
var validator = new CreateProductValidator();
var result = validator.TestValidate(new CreateProductRequest { Name = "" });
result.ShouldHaveValidationErrorFor(x => x.Name);
}
TestValidate() runs the validator synchronously and returns a TestValidationResult with fluent assertion helpers (ShouldHaveValidationErrorFor, ShouldNotHaveAnyValidationErrors, etc.). It requires no HTTP pipeline, no DI, and no database - making validator tests self-contained and fast.
For async validators, use TestValidateAsync().
One pattern I use on every project: a "valid baseline" fixture - a pre-populated request that passes all rules - so each test only mutates one field. This makes the failing case explicit and keeps tests readable.
Mistake 6: Putting Business Logic Inside Validators
Validators are for structural correctness - shape, presence, format, range. Business rules belong in the domain layer or a command handler. Mixing them creates a validator that is hard to test, hard to reuse, and coupled to infrastructure.
The line can be blurry, so a practical heuristic: if the rule requires a database call beyond a simple uniqueness check, or if the rule enforces a multi-step business invariant, it does not belong in a FluentValidation rule.
In the validator:
NotEmpty(),MaximumLength(),EmailAddress(),InclusiveBetween()Cross-field rules: shipping address required when
RequiresShippingis trueAsync uniqueness: email already registered? Username already taken?
Not in the validator:
"A SaaS tenant cannot have more than 5 active projects on the free plan" - that is a domain rule
"A product cannot be discounted below the supplier's minimum price" - that is a business invariant
Any rule that requires loading related entities and applying decision logic
Keeping validators thin also means they can be reused across controllers, minimal API endpoints, background job input, and message consumers without pulling in business layer dependencies.
Mistake 7: Forgetting to Register the Validation Pipeline for Minimal APIs
AddFluentValidationAutoValidation() hooks into the MVC model binding pipeline - it works automatically for controller actions. But for Minimal APIs, it does not fire automatically. Teams migrating from controllers sometimes discover this only in production.
For Minimal APIs, validation must be called explicitly:
app.MapPost("/products", async (
CreateProductRequest request,
IValidator<CreateProductRequest> validator,
IProductService service) =>
{
var result = await validator.ValidateAsync(request);
if (!result.IsValid)
return Results.ValidationProblem(result.ToDictionary());
var product = await service.CreateAsync(request);
return Results.Created($"/products/{product.Id}", product);
});
Results.ValidationProblem() maps the FluentValidation error dictionary to a standard Problem Details response (RFC 7807) with status 400. This is the idiomatic approach and matches what auto-validation produces in the controller pipeline.
An alternative is to encapsulate the validation call in an endpoint filter so you do not repeat it across every handler:
app.MapPost("/products", CreateProductHandler)
.WithValidation<CreateProductRequest>();
Where WithValidation<T> is an extension method wrapping an IEndpointFilter that calls ValidateAsync. The full endpoint filter implementation - including how it handles cascading errors and returns Problem Details - is in the GitHub repo.
Summary
These seven mistakes share a common thread: FluentValidation is expressive enough that the wrong pattern often passes compilation and even basic testing. The bugs only surface under load, in integration, or in edge-case inputs.
To recap:
CascadeMode on the rule, not the class - stop noise, not completeness
Register validators as scoped - never singleton when they inject scoped services
Use
When()/Unless()for conditional rules - not a tangledMust()Use
MustAsync()for database checks - never block on.ResultTest validators with
TestValidate()- unit tests, not just integration testsKeep business logic out of validators - validators validate shape; handlers enforce domain rules
Call
ValidateAsync()explicitly in Minimal APIs - auto-validation is MVC-only
For a detailed walkthrough of how FluentValidation fits into a complete ASP.NET Core API - alongside EF Core repositories, global error handling, and a full integration test suite - see ASP.NET Core Request Validation: FluentValidation vs DataAnnotations vs Built-In and the Zero to Production course.
If you want to see a complete validator setup with all these patterns in place - including the endpoint filter, the async uniqueness check, and the TestValidate() suite - the full source is available on Patreon alongside the GitHub starter repo.
FAQ
What is the difference between ClassLevelCascadeMode and RuleLevelCascadeMode in FluentValidation?
ClassLevelCascadeMode controls whether FluentValidation stops running rules for subsequent properties once any property fails. RuleLevelCascadeMode controls whether it stops running subsequent rules on the same property once one fails. For most cases, setting Cascade(CascadeMode.Stop) on individual rules gives the most precise control.
Does AddFluentValidationAutoValidation() still work in .NET 10?
Yes. As of FluentValidation 11.x (compatible with .NET 10), AddFluentValidationAutoValidation() is still the recommended way to enable automatic validation for MVC controllers. It no longer ships in the core FluentValidation package - you need FluentValidation.AspNetCore alongside it.
Can I use FluentValidation with Minimal APIs without writing a filter each time?
Yes. The cleanest approach is a reusable IEndpointFilter that calls ValidateAsync and returns Results.ValidationProblem() on failure. Register it as an extension method and apply it per route group to avoid repeating the pattern on every endpoint.
Why should I use TestValidate() instead of just testing via the HTTP pipeline?
TestValidate() runs the validator directly without needing a running web application, a database, or HTTP routing. Tests are faster, more focused, and fail at the correct level. HTTP integration tests are valuable too - but they should test the whole pipeline, not individual validation rules.
Is it safe to inject IValidator<T> directly into a handler instead of relying on auto-validation?
Yes, and in many teams this is preferred because it is explicit. Injecting the validator and calling ValidateAsync() yourself gives you full control over when validation runs, what happens on failure, and how errors are surfaced - which is especially useful in CQRS pipeline behaviors or Minimal API handlers.
What is the correct way to validate child objects in FluentValidation?
Use RuleForEach() for collections and SetValidator() to delegate to a child validator for nested objects. This keeps each validator single-responsibility and allows child validators to be tested independently.
Should validators be registered as Scoped or Transient?
Scoped is the default from AddValidatorsFromAssemblyContaining() and the right choice when validators inject scoped services (like repositories). Use Transient only if validators are stateless and never inject dependencies. Never use Singleton unless the validator has zero dependencies and zero mutable state.
About the Author
I'm Celin Daniel, Co-founder of Coding Droplets. I've been building .NET and ASP.NET Core systems in production for 13+ years - APIs, distributed backends, enterprise platforms. Everything I write here comes from real shipping experience: patterns that held up, trade-offs that bit us, and lessons learned the hard way.
GitHub: codingdroplets
YouTube: Coding Droplets
Website: codingdroplets.com







