.NET Development

FluentValidation in .NET 10: Clean Validation for Modern C# Applications

Team Nippysoft
24 min read
FluentValidation in .NET 10: Clean Validation for Modern C# Applications

Every application reaches a point where scattered validation logic becomes a liability. A check here in a controller, another there in a service method, a third buried inside a domain entity constructor. When validation rules live everywhere, they belong nowhere. Changes become risky because nobody can confidently answer where all the validation for a given operation actually lives. FluentValidation addresses this problem directly. It is an open-source .NET library that lets you define validation rules as dedicated classes, separate from both your domain models and your API layer. Each validator is a self-contained unit that can be tested independently, composed with other validators, and injected through dependency injection like any other service. In this article, you will learn what FluentValidation is, which problems it solves that Data Annotations and manual validation cannot, and how to implement it from scratch in .NET 10 with C# 14. You will also see how it integrates with MediatR pipeline behaviors in a Clean Architecture context, turning validation into a cross-cutting concern that never leaks into your business logic.

What Is FluentValidation and Why Does It Exist

FluentValidation is an open-source validation library for .NET created by Jeremy Skinner. It provides a fluent API built around the AbstractValidator<T> base class, where you define validation rules for a specific type using strongly-typed method chains. Instead of decorating properties with attributes, you write explicit rule expressions that read almost like plain English: RuleFor(x => x.Email).NotEmpty().EmailAddress().

The library has accumulated over 350 million downloads on NuGet, making it one of the most widely adopted validation solutions in the .NET ecosystem. Its popularity is not accidental. FluentValidation treats validation as what it actually is: business logic that deserves its own layer, its own tests, and its own evolution lifecycle independent from the models it validates.

Consider a product catalog system where the same CreateProductCommand needs different validation rules depending on the product category. With Data Annotations, you would need conditional attributes or custom validation attributes that quickly become unwieldy. With FluentValidation, you write a validator class that uses When and Unless conditions naturally within the fluent chain, keeping every rule visible in a single location.

Technical insight: FluentValidation intentionally avoids coupling to any specific framework. While it provides integration packages for ASP.NET Core, the core library has zero dependencies beyond .NET itself. This design decision means your validators work identically in API controllers, background workers, console applications, and integration tests without modification.

Problems That FluentValidation Solves

Separation of Concerns

Data Annotations mix validation metadata with model definitions. A [Required] or [MaxLength(100)] attribute on a property tells the model class about infrastructure concerns it should not know about. In Clean Architecture, your domain entities and command objects should express what they are, not how they should be validated. FluentValidation moves all validation logic into dedicated validator classes that live in the Application layer, leaving models clean and focused on their primary responsibility: carrying data.

Complex Rule Composition

Real-world validation is rarely just "required" and "max length." You need rules that depend on other properties: a discount percentage that must be zero when a product is not on sale. You need rules that call external services asynchronously: verifying that an email is not already registered. You need rules that apply conditionally based on the operation context: an update command that allows null for unchanged fields while a create command requires everything. FluentValidation handles all of these scenarios through When, Unless, Must, MustAsync, and Custom validators without forcing you into attribute gymnastics.

The net result is that validation rules are readable, testable, and maintainable. When a business rule changes, you modify exactly one validator class. When a bug appears in validation, you write a focused unit test for that specific rule.

Installation and Setup in .NET 10

Getting FluentValidation running in a .NET 10 project requires two NuGet packages. The core library provides the validator base classes and rule definitions. The dependency injection extensions package adds assembly scanning to register all validators automatically.

dotnet add package FluentValidation
dotnet add package FluentValidation.DependencyInjectionExtensions

Registration in your Program.cs uses a single line that scans the assembly for all classes inheriting from AbstractValidator<T>:

var builder = WebApplication.CreateBuilder(args);

// Register all validators from the assembly containing CreateProductValidator
builder.Services.AddValidatorsFromAssemblyContaining<CreateProductValidator>();

var app = builder.Build();
app.Run();

This registers every validator in the same assembly as CreateProductValidator with a scoped lifetime by default. In Clean Architecture, you would point this to your Application layer assembly, ensuring all validators are discovered regardless of how many you add. No manual registration per validator, no forgetting to wire up a new one.

Practical scenario: A team with 45 validators across different feature modules added a new endpoint with its validator. Because assembly scanning handles registration, the new validator worked immediately after creation without touching Program.cs. Compare this with manual DI registration where missing a single line causes silent validation bypass in production.

Core Concepts: Your First Validator

AbstractValidator and RuleFor

Every FluentValidation validator inherits from AbstractValidator<T>, where T is the type being validated. Rules are defined in the constructor using the RuleFor method, which takes a lambda expression pointing to the property you want to validate. You chain built-in validators after RuleFor to express your constraints.

Built-in Validators

FluentValidation ships with a comprehensive set of built-in validators that cover common scenarios:

  • NotEmpty / NotNull — ensures the value is present and not default
  • MaximumLength / MinimumLength — constrains string length
  • EmailAddress — validates email format
  • GreaterThan / LessThan / InclusiveBetween — numeric range validation
  • Matches — regex pattern validation
  • Must — custom predicate for business rules
  • SetValidator — delegates to a child validator for nested objects
  • ForEach — validates each element in a collection

Each validator supports .WithMessage() for custom error messages and .WithErrorCode() for machine-readable error identifiers that front-end applications can use for localization.

public class CreateProductValidator : AbstractValidator<CreateProductCommand>
{
    public CreateProductValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty().WithMessage("Product name is required.")
            .MaximumLength(200).WithMessage("Product name cannot exceed 200 characters.");

        RuleFor(x => x.Price)
            .GreaterThan(0).WithMessage("Price must be greater than zero.");

        RuleFor(x => x.Sku)
            .NotEmpty()
            .Matches(@"^[A-Z]{2,4}-\d{4,8}$")
            .WithMessage("SKU must follow the format XX-0000 (2-4 letters, dash, 4-8 digits).");

        RuleFor(x => x.CategoryId)
            .NotEmpty().WithMessage("Every product must belong to a category.");

        RuleFor(x => x.Description)
            .MaximumLength(2000)
            .When(x => !string.IsNullOrEmpty(x.Description));
    }
}

Practical scenario: This validator handles a real product creation workflow. The SKU rule uses a regex to enforce a company-specific format. The description validation only applies when a description is actually provided, avoiding false positives on optional fields. Notice how every rule reads as a clear business requirement, not a cryptic attribute.

Advanced Validation Rules

Custom Validators with Must and Custom

The Must validator accepts any predicate that returns a boolean. For more complex scenarios where you need to add multiple error messages or inspect the validation context, Custom gives you full control over the validation process. These two validators handle the 90% of cases where built-in validators are insufficient.

Conditional Rules: When and Unless

Business validation is inherently conditional. A shipping address is required only when the delivery method is not digital. A tax identification number is mandatory only for business accounts. When applies a rule only if a condition is true. Unless applies a rule only if a condition is false. Both support ApplyConditionTo.AllValidators to scope the condition to multiple chained rules at once.

Asynchronous Validation

Some rules require database lookups or external service calls. MustAsync accepts an async predicate, and ValidateAsync executes all rules including async ones. This is critical for rules like checking email uniqueness against a database.

public class UpdateOrderValidator : AbstractValidator<UpdateOrderCommand>
{
    public UpdateOrderValidator(IOrderRepository orderRepository)
    {
        RuleFor(x => x.OrderId)
            .NotEmpty()
            .MustAsync(async (id, cancellation) =>
                await orderRepository.ExistsAsync(id, cancellation))
            .WithMessage("The specified order does not exist.");

        RuleFor(x => x.Discount)
            .InclusiveBetween(0, 50)
            .When(x => x.ApplyDiscount)
            .WithMessage("Discount must be between 0% and 50%.");

        RuleFor(x => x.ShippingAddress)
            .NotEmpty()
            .Unless(x => x.DeliveryMethod == DeliveryMethod.Digital)
            .WithMessage("Shipping address is required for physical delivery.");

        RuleFor(x => x.Notes)
            .Must(notes => !notes.Contains("<script>"))
            .When(x => !string.IsNullOrEmpty(x.Notes))
            .WithMessage("Notes contain invalid content.");
    }
}

Technical insight: When using MustAsync, always call ValidateAsync instead of Validate. Calling the synchronous Validate method on a validator that contains async rules throws an AsyncValidatorInvokedSynchronouslyException. This is a deliberate design choice by FluentValidation to prevent deadlocks and ensure async validators execute on the correct synchronization context. In MediatR pipeline behaviors, this happens naturally since the behavior itself is async.

Integration with ASP.NET Core in .NET 10

Automatic Validation with Endpoint Filters (Minimal APIs)

.NET 10 Minimal APIs support endpoint filters, which provide a clean interception point for validation before the endpoint handler executes. Instead of manually calling validators inside every endpoint, you create a reusable filter that resolves the appropriate validator from DI and validates the request automatically.

public class ValidationEndpointFilter<TRequest>(
    IValidator<TRequest> validator) : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        if (context.Arguments.FirstOrDefault(a => a is TRequest) is not TRequest request)
            return await next(context);

        var result = await validator.ValidateAsync(request);
        if (!result.IsValid)
        {
            var errors = result.Errors
                .GroupBy(e => e.PropertyName)
                .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());

            return Results.ValidationProblem(errors);
        }

        return await next(context);
    }
}

Validation in Controllers

For controller-based APIs, inject IValidator<T> directly into the controller or action method. Call ValidateAsync and return a ValidationProblem response when the result is invalid. The pattern is straightforward but repetitive, which is why the pipeline approach shown later is preferable for larger applications.

Applying the filter to a Minimal API endpoint is a single method call:

app.MapPost("/api/products", async (CreateProductCommand command, IMediator mediator) =>
{
    var result = await mediator.Send(command);
    return Results.Created($"/api/products/{result.Id}", result);
})
.AddEndpointFilter<ValidationEndpointFilter<CreateProductCommand>>()
.WithName("CreateProduct")
.WithOpenApi();

Practical scenario: A team migrating from controllers to Minimal APIs replaced their validation action filter with this endpoint filter pattern. The migration took less than a day because the validators themselves did not change at all. Only the integration layer changed, demonstrating the value of separating validation logic from framework-specific plumbing.

FluentValidation in Clean Architecture with CQRS

Where Validators Live in the Architecture

In Clean Architecture, validators belong in the Application layer. They validate commands and queries, which are Application-layer concerns. Validators should never live in the Domain layer (domain entities enforce their own invariants through constructors and methods) and never in the API layer (which should remain thin). Place validators alongside their corresponding command or query, typically in the same feature folder.

MediatR Pipeline Behavior for Validation

The most powerful integration pattern is a MediatR pipeline behavior that intercepts every request, runs all registered validators, and short-circuits with a validation error before the handler ever executes. This is the single best investment you can make in a CQRS architecture because it guarantees that no command or query reaches a handler without passing validation first.

public sealed class ValidationBehavior<TRequest, TResponse>(
    IEnumerable<IValidator<TRequest>> validators)
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        if (!validators.Any())
            return await next(cancellationToken);

        var context = new ValidationContext<TRequest>(request);

        var validationResults = await Task.WhenAll(
            validators.Select(v => v.ValidateAsync(context, cancellationToken)));

        var errors = validationResults
            .SelectMany(r => r.Errors)
            .Where(e => e is not null)
            .ToList();

        if (errors.Count is not 0)
            throw new ValidationException(errors);

        return await next(cancellationToken);
    }
}

This behavior uses primary constructors from C# 14 and runs all validators in parallel using Task.WhenAll. When any validation fails, it throws a ValidationException that your exception handling middleware converts to a standardized error response.

Register everything in Program.cs:

var builder = WebApplication.CreateBuilder(args);

// Register MediatR with pipeline behaviors
builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssemblyContaining<CreateProductHandler>();
    cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
});

// Register all FluentValidation validators
builder.Services.AddValidatorsFromAssemblyContaining<CreateProductValidator>();

var app = builder.Build();
app.Run();
MediatR Validation Pipeline Flow API Request MediatR Pipeline Validation Behavior IValidator<T> Valid? Yes Handler 200 OK No Validation Exception 400 Bad Request Pipeline behaviors execute in registration order (outermost to innermost)

Technical insight: Pipeline behavior order matters. Register ValidationBehavior before logging or performance behaviors so that invalid requests are rejected before consuming resources. If you have both a LoggingBehavior and a ValidationBehavior, register validation first. MediatR executes behaviors in the order they are registered, creating a Russian-doll nesting pattern where the first registered behavior is the outermost wrapper.

Testing Your Validators

FluentValidation provides a dedicated testing API through the TestValidate extension method. This method returns a TestValidationResult that exposes ShouldHaveValidationErrorFor and ShouldNotHaveValidationErrorFor for precise assertions.

Best practices for validator testing:

  1. Test one rule per test method — isolate each rule to pinpoint failures quickly.
  2. Test both valid and invalid scenarios — verify that rules reject bad input and accept good input.
  3. Test boundary values — for numeric ranges and string lengths, test the exact boundary.
  4. Test conditional rules explicitly — create test cases where the condition is true and false.
  5. Mock async dependencies — use Moq or NSubstitute for validators that call repositories.
public class CreateProductValidatorTests
{
    private readonly CreateProductValidator _validator = new();

    [Fact]
    public void Should_have_error_when_Name_is_empty()
    {
        var command = new CreateProductCommand { Name = "", Price = 10, Sku = "AB-1234" };
        var result = _validator.TestValidate(command);
        result.ShouldHaveValidationErrorFor(x => x.Name);
    }

    [Fact]
    public void Should_not_have_error_when_valid_command()
    {
        var command = new CreateProductCommand
        {
            Name = "Widget Pro",
            Price = 29.99m,
            Sku = "WP-12345",
            CategoryId = Guid.NewGuid()
        };
        var result = _validator.TestValidate(command);
        result.ShouldNotHaveAnyValidationErrors();
    }

    [Theory]
    [InlineData("ab-1234")]
    [InlineData("ABCDE-123")]
    [InlineData("AB1234")]
    public void Should_have_error_when_Sku_format_invalid(string sku)
    {
        var command = new CreateProductCommand { Name = "Test", Price = 10, Sku = sku };
        var result = _validator.TestValidate(command);
        result.ShouldHaveValidationErrorFor(x => x.Sku);
    }
}

Practical scenario: A development team discovered a production bug where products with zero price were being created. They traced it to a missing GreaterThan(0) rule. After adding the rule, they wrote a parameterized test covering zero, negative, and valid prices. The bug fix and test took twelve minutes. Finding the same bug through integration testing or customer reports would have cost significantly more.

FluentValidation vs Data Annotations vs Manual Validation

Choosing a validation strategy affects your architecture for years. This comparison covers the dimensions that matter most for maintainable applications:

CriteriaFluentValidationData AnnotationsManual Validation
Separation of concernsFull separation in dedicated classesMixed into model definitionsScattered across services
Complex conditional rulesWhen/Unless with full lambda supportLimited, requires custom attributesPossible but verbose
Async validationBuilt-in MustAsync and ValidateAsyncNot supported nativelyManual async implementation
TestabilityTestValidate API, fully unit-testableRequires ModelState simulationTestable but no dedicated API
CQRS integrationNative via MediatR pipeline behaviorsNo pipeline conceptCustom middleware required
Cross-property validationRuleFor with lambda access to full objectIValidatableObject, limitedFull access, no structure
ReusabilityInclude() to compose validatorsShared attributesUtility methods
Error message customizationWithMessage, WithErrorCode, localizationErrorMessage propertyFully custom
Collection validationRuleForEach with child validatorsLimitedManual iteration
Framework couplingNone (core library)Tied to System.ComponentModelNone
Learning curveModerate, fluent API is intuitiveLow, attribute-basedLow but scales poorly

For applications with fewer than ten validation rules and no complex business logic, Data Annotations are sufficient. For everything else, FluentValidation provides a significantly better developer experience and architectural outcome.

Common Mistakes Developers Make

After reviewing hundreds of FluentValidation implementations across production codebases, these are the most frequent problems:

Placing validators in the Domain layer. Domain entities enforce invariants through their constructors and methods. Validators validate external input (commands, queries, DTOs) and belong in the Application layer. Mixing these responsibilities violates the Single Responsibility Principle.

Calling Validate instead of ValidateAsync. If any rule in your validator uses MustAsync, WhenAsync, or any other async method, you must call ValidateAsync. The synchronous Validate method will throw an exception at runtime, and this bug only surfaces when the async rule path is exercised.

Creating monolithic validators. A validator with 50+ rules is a code smell. Use Include(new AddressValidator()) to compose smaller, focused validators. This also enables reuse when multiple commands share common validated properties.

Forgetting DI registration. Adding a new validator class but forgetting that your DI setup uses manual registration instead of assembly scanning. The validator simply never runs, and invalid data enters the system silently.

Ignoring CascadeMode. By default, FluentValidation continues evaluating all rules even after the first failure. For expensive validations, set RuleLevelCascadeMode = CascadeMode.Stop in the validator constructor to short-circuit after the first failure per property.

Mixing FluentValidation with Data Annotations on the same model. This creates two validation systems running independently, often with contradictory rules. Choose one approach per bounded context and apply it consistently.

Practical scenario: A team deployed a registration endpoint where the validator used MustAsync to check email uniqueness. During development, they only tested with valid emails, so the async path was never exercised. In production, the first duplicate email registration crashed the endpoint with an AsyncValidatorInvokedSynchronouslyException because the controller called Validate instead of ValidateAsync. The fix was a one-word change; the outage lasted 45 minutes.

Performance and Scalability

FluentValidation adds negligible overhead for synchronous validation rules. The library uses compiled expression trees for property access, making property resolution nearly as fast as direct property access. For the vast majority of applications, validation time is measured in microseconds and is invisible compared to database queries or network calls.

For high-throughput scenarios, consider these optimizations:

CascadeMode.Stop prevents executing subsequent rules after the first failure on a property. If your first rule checks NotEmpty and the second calls an external service, stopping at the first failure avoids unnecessary network calls.

Validator lifetime matters. Register validators as singletons when they have no injected dependencies. Scoped or transient lifetimes create garbage collection pressure under high request volumes. When validators require scoped services (like a DbContext), keep them scoped but be aware of the allocation cost.

RuleForEach with large collections can become expensive. If you are validating a collection with thousands of elements, consider validating only a subset or moving bulk validation to a background process. A validator that takes 200ms per item on a collection of 5,000 items will block the request for over 16 minutes.

Frequently Asked Questions

Can I use FluentValidation and Data Annotations together?

Technically yes, but it is not recommended. Running two validation systems simultaneously creates confusion about which system catches which error. If you are migrating from Data Annotations, convert validators one at a time and remove the corresponding annotations as you go. A gradual migration is preferable to maintaining two parallel systems.

Does FluentValidation work with Minimal APIs in .NET 10?

Yes. FluentValidation has no dependency on MVC or controllers. You integrate it through endpoint filters as shown in this article, or through MediatR pipeline behaviors. The core validation logic is framework-agnostic, so it works identically whether your request comes from a Minimal API endpoint, a controller, a gRPC service, or a background worker.

How do I validate nested objects and collections?

Use SetValidator for nested objects and RuleForEach combined with SetValidator for collections. For example: RuleFor(x => x.Address).SetValidator(new AddressValidator()) delegates to a separate validator. For collections: RuleForEach(x => x.LineItems).SetValidator(new LineItemValidator()) validates each item independently.

What is the performance impact compared to Data Annotations?

For synchronous rules, the difference is negligible — microseconds per validation call. FluentValidation uses compiled expression trees for property access. The overhead only becomes noticeable with async validators that perform I/O operations, and that cost comes from the I/O itself, not from FluentValidation.

Should I throw exceptions or return error results from validation?

Both approaches are valid but serve different contexts. In MediatR pipeline behaviors, throwing a ValidationException is the standard pattern because the behavior has no return type control. In direct usage, returning a ValidationResult and checking IsValid avoids the performance cost of exception handling. Use exceptions for cross-cutting pipeline validation and results for in-handler validation.

Start Validating the Right Way

FluentValidation transforms validation from a scattered obligation into a structured, testable, and maintainable part of your architecture. It separates validation logic from models, supports complex conditional and async rules, integrates naturally with MediatR CQRS pipelines, and produces validators that are as easy to test as any other unit in your codebase.

The path to adoption does not require a complete rewrite. Pick one endpoint — ideally one with complex validation logic spread across controllers and services. Extract those rules into a single AbstractValidator<T> class. Register it with dependency injection. Write three tests: one for valid input, one for the most critical invalid case, and one for a boundary condition. Run those tests. Feel the difference between testing a validator in isolation versus simulating HTTP requests through a controller. That single migration will demonstrate the value more effectively than any article ever could.

Subscribe

Get the latest posts delivered right to your inbox.

This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.

Comments

No comments yet. Be the first to share your thoughts!

Subscribed!

Registered! A confirmation link has been sent to your email address. If you don't see it, please check your spam folder.

Error

An error occurred. Please try again.