# 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](https://www.patreon.com/CodingDroplets) - 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](https://aspnetcoreapi.codingdroplets.com/) walks through every one of these scenarios inside one running codebase, so the context never gets lost.

[![ASP.NET Core Web API: Zero to Production](https://newsletter.codingdroplets.com/images/aspnet-core-api-course-banner-1.jpg align="center")](https://aspnetcoreapi.codingdroplets.com/)

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:

```csharp
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:

```csharp
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:

```csharp
// 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:

```csharp
// 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:

```csharp
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()`:

```csharp
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()`:

```csharp
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()`:

```csharp
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:

```csharp
[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 `RequiresShipping` is true
    
*   Async 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:

```csharp
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:

```csharp
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](https://github.com/codingdroplets/dotnet-fluent-validation-api).

* * *

## 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:

1.  **CascadeMode on the rule, not the class** - stop noise, not completeness
    
2.  **Register validators as scoped** - never singleton when they inject scoped services
    
3.  **Use** `When()` **/** `Unless()` **for conditional rules** - not a tangled `Must()`
    
4.  **Use** `MustAsync()` **for database checks** - never block on `.Result`
    
5.  **Test validators with** `TestValidate()` - unit tests, not just integration tests
    
6.  **Keep business logic out of validators** - validators validate shape; handlers enforce domain rules
    
7.  **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](https://codingdroplets.com/aspnet-core-request-validation-enterprise-decision-guide) and the [Zero to Production course](https://aspnetcoreapi.codingdroplets.com/).

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](https://www.patreon.com/CodingDroplets) 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](https://codingdroplets.com/). 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](http://github.com/codingdroplets/)
    
*   YouTube: [Coding Droplets](https://www.youtube.com/@CodingDroplets)
    
*   Website: [codingdroplets.com](https://codingdroplets.com/)
