FluentValidation in .NET: A Practical Guide to Validation

Learn how FluentValidation helps you build clean, maintainable validation rules in .NET applications, from simple models to CQRS and MediatR pipelines.

FluentValidation in .NET: A Practical Guide to Validation cover

Validation is an important part of almost every .NET application. As applications grow, validation rules can become more complex. Keeping all those rules inside controllers, models, or business logic can make the code harder to maintain.

ASP.NET Core provides Data Annotations for common validation scenarios, which works well for simple requirements. However, when validation involves conditional rules, custom logic, comparing multiple properties, or validating nested objects, a more flexible approach can be useful.

This is where FluentValidation comes in.

FluentValidation is a popular .NET library that allows you to define validation rules using C# code instead of placing validation attributes directly on your models. It provides a clean way to keep validation logic separate from the classes being validated while giving you a wide range of built-in and custom validation options.

In this article, we will start by understanding how FluentValidation works and explore the different types of validation rules it provides. We will then compare it with Data Annotations and see how the same validation approach can be used in a normal ASP.NET Core API, a CQRS implementation without MediatR, and a CQRS implementation using MediatR Pipeline Behaviors.

What is FluentValidation?

FluentValidation is a .NET library used to build strongly typed validation rules using C# code. Instead of adding validation attributes directly to a model, you create a separate validator class that contains the rules for that model.

For example, instead of putting attributes like [Required] or [StringLength] on a request model, we can create a validator:

public class CreateProductRequestValidator : AbstractValidator<CreateProductRequest> { public CreateProductRequestValidator() { RuleFor(x => x.Name) .NotEmpty() .MaximumLength(100); RuleFor(x => x.Price) .GreaterThan(0); } }

Here, CreateProductRequestValidator is responsible for validating CreateProductRequest. Each RuleFor() defines a rule for a specific property.

The main benefit of this approach is that validation logic stays separate from the model itself. The model can focus on representing the data, while the validator contains the rules that determine whether that data is valid.

FluentValidation also provides many built-in validators for common requirements, along with options for conditional validation and custom validation logic. This makes it possible to handle both simple rules and more complex validation scenarios without putting that logic directly into controllers or business logic.

Another important point is that FluentValidation does not depend on CQRS or MediatR. It can be used in a regular ASP.NET Core API, a CQRS application without MediatR, or with MediatR Pipeline Behaviors. The validation library and the way we execute those validations are separate concerns.

How FluentValidation Works

FluentValidation works by defining a set of rules for a particular model or request and then running those rules against an object when validation is required. Each rule checks a specific condition, such as whether a value is present, whether it falls within an allowed range, or whether it matches a particular format.

When the validation runs, FluentValidation evaluates the defined rules and produces a ValidationResult. If all the rules pass, the result is valid. If one or more rules fail, the result contains details about each validation failure, including the property that failed and the associated error message.

The validation process can be thought of as three simple steps: define the rules, validate the object, and handle the validation result. This keeps the actual validation rules separate from the code that processes the request and makes it easier to add or change rules as the application grows.

In the next section, we will create a validator for our product request and see how these rules are defined in code.

Installing FluentValidation

To add FluentValidation to the project, install the FluentValidation NuGet package using the .NET CLI:

dotnet add package FluentValidation

You can also install the package through the NuGet Package Manager in Visual Studio by searching for FluentValidation and adding it to the project.

Once the package is installed, we can start creating validators and defining validation rules for our models.

Creating Your First Validator

Let's start with a simple product request that contains a product name and price.

public class CreateProductRequest { public string Name { get; set; } public decimal Price { get; set; } }

We can create a separate validator for this request and define the validation rules inside it:

public class CreateProductRequestValidator : AbstractValidator<CreateProductRequest> { public CreateProductRequestValidator() { RuleFor(x => x.Name) .NotEmpty() .MaximumLength(100); RuleFor(x => x.Price) .GreaterThan(0); } }

RuleFor() specifies the property that we want to validate. The validation methods that follow it define the conditions that property must satisfy. Here, Name cannot be empty and cannot contain more than 100 characters, while Price must be greater than zero.

Multiple rules can be chained together for the same property, as we have done with Name. We can also define separate rules for different properties within the same validator. This gives us a single place to define and maintain the validation rules for a particular request.

Common Validation Rules

FluentValidation provides a wide range of built-in validators, so most common validation requirements can be handled without writing custom validation logic. The rules can also be combined, allowing us to apply multiple conditions to the same property.

String Validation

For string properties, some of the most commonly used validators are NotEmpty(), NotNull(), MinimumLength(), MaximumLength(), and Length().

RuleFor(x => x.Name) .NotEmpty() .MinimumLength(3) .MaximumLength(100);

NotEmpty() ensures that a value is provided, while MinimumLength() and MaximumLength() restrict the allowed length of the string. If you need an exact range, Length() can be used instead.

Numeric Validation

For numeric values, FluentValidation provides validators such as GreaterThan(), GreaterThanOrEqualTo(), LessThan(), LessThanOrEqualTo(), and InclusiveBetween().

RuleFor(x => x.Price) .GreaterThan(0); RuleFor(x => x.Quantity) .InclusiveBetween(1, 100);

Here, Price must be greater than zero, while Quantity must be between 1 and 100, including both boundaries.

Format Validation

FluentValidation also includes validators for commonly required formats. For example, EmailAddress() can be used when a property should contain an email address, while Matches() can be used when the value needs to follow a specific regular expression.

RuleFor(x => x.Email) .NotEmpty() .EmailAddress(); RuleFor(x => x.PhoneNumber) .Matches(@"^\d{10}$");

These validators are useful when validation depends not only on whether a value exists, but also on whether it follows an expected format.

Comparison Validation

You can also compare a property with another value or another property.

RuleFor(x => x.ConfirmPassword) .Equal(x => x.Password);

This ensures that ConfirmPassword contains the same value as Password. Similar validators such as NotEqual(), GreaterThan(), and LessThan() can also be used when comparing values.

These are some of the commonly used built-in validators. FluentValidation provides many more options, but these cover most basic validation requirements and give us a good foundation for handling more advanced validation scenarios.

Custom Error Messages

By default, FluentValidation provides an error message when a validation rule fails. However, you may want to provide a message that is more specific to the validation rule or easier for the API consumer to understand.

The WithMessage() method allows us to customize the message for a particular rule:

RuleFor(x => x.Name) .NotEmpty() .WithMessage("Product name is required."); RuleFor(x => x.Price) .GreaterThan(0) .WithMessage("Product price must be greater than zero.");

The message is associated with the rule immediately before WithMessage(). This means we can have different messages for different validation failures, even when multiple rules are defined for the same property.

Custom messages become particularly useful when the default validation message does not provide enough context or when you want the validation errors returned by your API to follow a consistent format.

Conditional Validation

Sometimes a validation rule should only be applied when a specific condition is met. FluentValidation provides When() for these scenarios, allowing us to control when a rule or a group of rules should be executed.

For example, suppose a product can have an optional discount. If a discount is provided, it must be greater than zero:

RuleFor(x => x.Discount) .GreaterThan(0) .When(x => x.Discount.HasValue);

Here, the price is validated only when Discount contains a value. If no discount is provided, the validation rule is skipped.

Conditions can also depend on another property. Suppose a product has a warranty option, and warranty-related information is required only when the warranty is selected:

RuleFor(x => x.WarrantyMonths) .GreaterThan(0) .When(x => x.HasWarranty);

When multiple properties depend on the same condition, we can group the rules inside a When() block instead of repeating the condition for every rule:

When(x => x.HasWarranty, () => { RuleFor(x => x.WarrantyMonths) .GreaterThan(0); RuleFor(x => x.WarrantyProvider) .NotEmpty(); RuleFor(x => x.WarrantyTerms) .NotEmpty(); });

All three rules in this block are executed only when HasWarranty is true. This is useful when several properties become required based on the state of another property.

FluentValidation also provides Unless(), which can be used when a rule should normally run but needs to be skipped when a particular condition is true. For example, consider a newsletter subscription where a phone number is required for users who have opted for SMS notifications, but the validation should not run for users who have not enabled SMS notifications:

RuleFor(x => x.PhoneNumber) .NotEmpty() .Unless(x => !x.ReceiveSmsNotifications);

Here, the phone number is validated unless ReceiveSmsNotifications is false. In other words, the rule runs when SMS notifications are enabled and is skipped when they are disabled.

When() and Unless() provide a simple way to handle validation that depends on the state of the object, without having to write separate validation logic outside the validator.

Custom Validation with Must()

Built-in validators cover many common validation requirements, but sometimes the validation depends on custom business rules. In these cases, Must() allows us to provide our own condition instead of relying only on the validators provided by FluentValidation.

For example, suppose a product price must be a multiple of 100. This is a rule specific to our application, so we can define the condition ourselves:

RuleFor(x => x.Price) .Must(price => price % 100 == 0) .WithMessage("Price must be a multiple of 100.");

The condition passed to Must() should return true when the value is valid and false when it is not. When the condition returns false, FluentValidation treats it as a validation failure and uses the message provided through WithMessage().

Must() can also be used when validation depends on more than one property. For example, a discounted price should always be lower than the original price:

RuleFor(x => x.DiscountedPrice) .Must((product, discountedPrice) => discountedPrice < product.Price) .WithMessage("Discounted price must be lower than the original price.");

In this case, the validation needs both DiscountedPrice and Price. The first parameter gives us access to the complete object, while the second parameter contains the value of the property being validated. This allows us to create rules based on relationships between multiple properties.

Must() is especially useful when a validation requirement is specific to the application's business rules and cannot be expressed clearly using the standard validators. It keeps that custom condition inside the validator while still following the same validation flow as the built-in rules.

Asynchronous Validation with MustAsync()

Some validation rules require more than checking the value available in the request. For example, when creating a product, we may want to make sure that the product code is not already being used by another product. Since this information comes from the database, the validation needs to perform an asynchronous operation.

FluentValidation provides MustAsync() for these scenarios. We can inject the required dependency into the validator and use it when defining the validation rule.

Suppose our product entity contains a ProductCode:

public class Product { public int Id { get; set; } public string Name { get; set; } public string ProductCode { get; set; } public decimal Price { get; set; } }

Our request can contain the same product code:

public class CreateProductRequest { public string Name { get; set; } public string ProductCode { get; set; } public decimal Price { get; set; } }

Assume we already have an EF Core DbContext:

public class ApplicationDbContext : DbContext { public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) { } public DbSet<Product> Products { get; set; } }

We can inject the ApplicationDbContext into the validator and use it to check whether the product code already exists:

public class CreateProductRequestValidator : AbstractValidator<CreateProductRequest> { private readonly ApplicationDbContext _dbContext; public CreateProductRequestValidator(ApplicationDbContext dbContext) { _dbContext = dbContext; RuleFor(x => x.ProductCode) .NotEmpty() .MustAsync(IsProductCodeUnique) .WithMessage("Product code already exists."); RuleFor(x => x.Name) .NotEmpty() .MaximumLength(100); RuleFor(x => x.Price) .GreaterThan(0); } private async Task<bool> IsProductCodeUnique(string productCode, CancellationToken cancellationToken) { return !await _dbContext.Products.AnyAsync(x => x.ProductCode == productCode, cancellationToken); } }

The IsProductCodeUnique() method queries the database asynchronously using AnyAsync(). If a product with the same code already exists, the method returns false, causing the validation rule to fail and the specified error message to be added to the validation result.

Since this validation performs an asynchronous operation, the validator must also be executed asynchronously using ValidateAsync():

var result = await validator.ValidateAsync(request); if (!result.IsValid) { // Handle validation errors }

Using Validate() when the validator contains asynchronous rules is not appropriate. ValidateAsync() executes both synchronous and asynchronous validation rules and allows asynchronous operations such as database queries or external service calls to be handled correctly.

The dependency injection registration required to resolve this validator is covered in the integration sections below.

Validating Complex Objects and Collections

Validation is not limited to simple properties such as strings and numbers. A request can also contain nested objects or collections, and FluentValidation provides ways to validate those properties using separate validators.

Suppose our product request contains supplier information:

public class CreateProductRequest { public string Name { get; set; } public decimal Price { get; set; } public SupplierRequest Supplier { get; set; } } public class SupplierRequest { public string Name { get; set; } public string Email { get; set; } }

We can create a separate validator for SupplierRequest:

public class SupplierRequestValidator : AbstractValidator<SupplierRequest> { public SupplierRequestValidator() { RuleFor(x => x.Name) .NotEmpty(); RuleFor(x => x.Email) .NotEmpty() .EmailAddress(); } }

Then we can use SetValidator() in the product validator:

public class CreateProductRequestValidator : AbstractValidator<CreateProductRequest> { public CreateProductRequestValidator() { RuleFor(x => x.Name) .NotEmpty(); RuleFor(x => x.Price) .GreaterThan(0); RuleFor(x => x.Supplier) .SetValidator(new SupplierRequestValidator()); } }

When CreateProductRequest is validated, FluentValidation also runs the SupplierRequestValidator for the nested Supplier object. This allows each object to have its own validation rules instead of putting all the rules into one large validator.

The same approach can be used for collections. Suppose a product request contains multiple tags:

public class CreateProductRequest { public string Name { get; set; } public List<string> Tags { get; set; } }

We can validate each item in the collection using RuleForEach():

RuleForEach(x => x.Tags) .NotEmpty() .MaximumLength(30);

Here, the rules are applied to every item in the Tags collection. If the request contains several tags, each tag is checked independently.

For collections containing complex objects, RuleForEach() can be combined with SetValidator():

RuleForEach(x => x.Variants) .SetValidator(new ProductVariantValidator());

This keeps the validation of nested objects and collection items separated into their own validators, making larger request models easier to organize and maintain.

Understanding Validation Results

After FluentValidation executes the rules, it returns a ValidationResult that tells us whether the object passed validation and, if not, which rules failed. We can check the IsValid property to determine whether the validation was successful.

For example:

var result = await validator.ValidateAsync(request); if (result.IsValid) { // Validation succeeded }

When one or more rules fail, IsValid becomes false and the Errors collection contains the details of those failures. Each failure provides information such as the property that failed and the message associated with that rule.

var result = await validator.ValidateAsync(request); if (!result.IsValid) { foreach (var error in result.Errors) { Console.WriteLine($"{error.PropertyName}: {error.ErrorMessage}"); } }

For example, if the Name is empty and the Price is zero, the result can contain separate failures for both properties. This allows the application to return all relevant validation errors together instead of stopping after the first failed rule.

The ValidationResult is therefore the bridge between the validation rules we define and the code that needs to handle those results. How these errors are returned to the client depends on the application and how validation is integrated into it.

FluentValidation vs Data Annotations

Data Annotations are the traditional way of adding validation rules to models in ASP.NET Core. They use attributes such as [Required], [StringLength], [Range], and [EmailAddress] directly on the properties that need validation.

For example, the same product request can be validated using Data Annotations like this:

public class CreateProductRequest { [Required] [StringLength(100)] public string Name { get; set; } [Range(0.01, double.MaxValue)] public decimal Price { get; set; } }

The equivalent validation using FluentValidation keeps those rules in a separate validator:

public class CreateProductRequestValidator : AbstractValidator<CreateProductRequest> { public CreateProductRequestValidator() { RuleFor(x => x.Name) .NotEmpty() .MaximumLength(100); RuleFor(x => x.Price) .GreaterThan(0); } }

Both approaches can handle common validation requirements such as required values, length restrictions, numeric ranges, and email formats. For simple models with straightforward rules, Data Annotations can be a perfectly reasonable choice and require very little setup.

The difference becomes more noticeable when validation starts depending on conditions, multiple properties, or custom business rules. For example, with FluentValidation we can easily express a rule where the discounted price must be lower than the original price:

RuleFor(x => x.DiscountedPrice) .LessThan(x => x.Price);

We can also group conditional rules, use Must() for custom conditions, validate nested objects and collections, and keep all of these rules outside the model. Data Annotations can be extended with custom ValidationAttribute implementations for more complex scenarios, but FluentValidation provides a dedicated API designed around defining and composing validation rules.

Another difference is where the validation logic lives. With Data Annotations, the validation rules are part of the model through attributes. With FluentValidation, the model remains focused on representing data while the validation rules are defined separately.

There is no requirement to replace Data Annotations in every project. If the validation requirements are simple, they may be sufficient. FluentValidation becomes particularly useful when the application has a larger number of validation rules or when those rules require conditions, custom logic, or relationships between multiple properties.

Using FluentValidation in a Normal ASP.NET Core API

Now let's integrate FluentValidation into a regular ASP.NET Core API. The main goal here is to connect the validator we created with the application's dependency injection system and execute it when a request is received.

First, register the validators with the application's service collection:

builder.Services.AddValidatorsFromAssemblyContaining< CreateProductRequestValidator>();

This scans the assembly containing CreateProductRequestValidator and registers the validators so they can be resolved through dependency injection. Registration only makes the validators available to the application. It does not execute validation automatically.

We can then inject IValidator<CreateProductRequest> into the controller:

public class ProductsController : ControllerBase { private readonly IValidator<CreateProductRequest> _validator; public ProductsController(IValidator<CreateProductRequest> validator) { _validator = validator; } }

When the request reaches the endpoint, we can explicitly execute the validator:

[HttpPost] public async Task<IActionResult> Create(CreateProductRequest request) { var result = await _validator.ValidateAsync(request); if (!result.IsValid) { return BadRequest(result.Errors); } // Create product return Ok(); }

The controller receives the request, passes it to the validator, and checks the ValidationResult. If validation fails, the API returns the validation errors without continuing with the rest of the operation. If validation succeeds, the application can continue with the actual business operation.

This approach also works with the asynchronous rules we saw earlier. Because we are using ValidateAsync(), both regular validation rules and rules that perform asynchronous operations can be executed as part of the same validation process.

Using FluentValidation with CQRS and MediatR

When FluentValidation is used with CQRS and MediatR, the validation rules themselves do not need to change. We can continue using a validator for the command, but instead of injecting the validator into the controller and calling ValidateAsync() manually, validation can be handled as part of the MediatR request pipeline.

For example, our command can have its own validator:

public class CreateProductCommandValidator : AbstractValidator<CreateProductCommand> { public CreateProductCommandValidator() { RuleFor(x => x.Name) .NotEmpty() .MaximumLength(100); RuleFor(x => x.ProductCode) .NotEmpty(); RuleFor(x => x.Price) .GreaterThan(0); } }

The controller only needs to create the command and send it through MediatR:

[HttpPost] public async Task<IActionResult> Create( CreateProductRequest request) { var command = new CreateProductCommand { Name = request.Name, ProductCode = request.ProductCode, Price = request.Price }; var result = await _mediator.Send(command); return Ok(result); }

There is no explicit call to ValidateAsync() in the controller. The controller does not need to know which validator belongs to the command or how validation is performed. The command is sent through MediatR, and the validation behavior can execute the appropriate validator before the request reaches the handler.

The validators still need to be registered with dependency injection so they can be resolved when the request is processed:

builder.Services.AddValidatorsFromAssemblyContaining<CreateProductCommandValidator>();

The important difference from the normal API approach is that validation is no longer part of the controller's request handling code. The controller remains focused on receiving the request and sending the command, while the validation is handled separately before the command reaches its handler.

Validation Pipeline Behavior

Instead of calling ValidateAsync() manually in every controller, we can move the validation into a MediatR Pipeline Behavior. The behavior runs before the request reaches its handler and executes the validator for that request. If validation fails, the handler is not executed.

We can create a simple generic validation behavior:

public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse> { 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) { foreach (var validator in _validators) { var result = await validator.ValidateAsync(request, cancellationToken); if (!result.IsValid) { throw new ValidationException(result.Errors); } } return await next(); } }

The behavior receives the validators registered for the current request type through dependency injection. For example, when a CreateProductCommand is sent, the corresponding CreateProductCommandValidator is provided to the behavior and its rules are executed.

If the validation succeeds, the code continues to next(). This allows the request to move forward to the next step in the MediatR pipeline and eventually reach the handler. If validation fails, a ValidationException is thrown and next() is never called.

We use ValidateAsync() here because the validator may contain asynchronous rules such as MustAsync(). This allows the same pipeline behavior to handle both normal and asynchronous validation.

Finally, we register the behavior with MediatR:

builder.Services.AddMediatR(config => { config.RegisterServicesFromAssembly(typeof(Program).Assembly); config.AddOpenBehavior(typeof(ValidationBehavior<,>)); });

The open generic ValidationBehavior<,> allows the same behavior to work with different request and response types. Once registered, validation becomes part of the request pipeline instead of something that needs to be manually added to each controller.

Conclusion

FluentValidation provides a clean way to keep validation rules separate from the models and requests they belong to. We started with basic rules and gradually moved into conditional validation, custom rules, nested objects, collections, validation results, and asynchronous validation.

We also compared FluentValidation with Data Annotations and saw where each approach can fit. For simple validation requirements, Data Annotations can be enough. When validation becomes more complex, FluentValidation provides more flexibility while keeping the rules organized.

Finally, we integrated FluentValidation into a normal ASP.NET Core API and then used it with CQRS and MediatR. With a MediatR Pipeline Behavior, validation can be handled before the request reaches the handler, keeping controllers and handlers focused on their own responsibilities.

The main takeaway is that FluentValidation is not tied to any particular architecture. It can be used wherever you need clear, maintainable validation rules, whether that is a simple API or a larger application using CQRS and MediatR.