Using FluentValidation in C#
Learn FluentValidation in C# to define clean and maintainable validation rules for models and application inputs.
FluentValidation is a popular library in C# that allows you to define validation rules using a fluent API. It lets you create reusable and testable rules that are independent of model classes. This article provides a practical guide—from installation to advanced features, including ASP.NET Core integration and unit testing.
Installation
For the core package and optional ASP.NET Core integration:
dotnet add package FluentValidation
dotnet add package FluentValidation.AspNetCore # For web projects
For desktop (WPF/WinForms) or service projects, only FluentValidation is needed.
Basic Usage: A Simple Validator
A validator class derives from AbstractValidator<T>, and rules are written using RuleFor.
using FluentValidation;
public class UserDto
{
public string? Name { get; set; }
public string? Email { get; set; }
public int Age { get; set; }
}
public class UserDtoValidator : AbstractValidator<UserDto>
{
public UserDtoValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Name cannot be empty")
.Length(2, 50).WithMessage("Name must be between 2 and 50 characters");
RuleFor(x => x.Email)
.NotEmpty().WithMessage("Email is required")
.EmailAddress().WithMessage("Please provide a valid email");
RuleFor(x => x.Age)
.InclusiveBetween(18, 120).WithMessage("Age must be between 18 and 120");
}
}
To validate: var result = new UserDtoValidator().Validate(model);
Errors can be read via result.Errors.
Messages, Severity, and Cascade Mode
- Use
WithMessage()for custom error messages. - Use
WithSeverity(Severity.Error|Warning|Info)to specify priority. - Use
Cascade(CascadeMode.Stop)to stop further validation on the first failure.
RuleFor(x => x.Name)
.Cascade(CascadeMode.Stop)
.NotEmpty().WithMessage("Name is required").WithSeverity(Severity.Error)
.MinimumLength(2).WithMessage("Name must be at least 2 characters long");
Conditional Rules: When / Unless
Used to apply a rule only under certain conditions.
RuleFor(x => x.Email)
.NotEmpty().EmailAddress();
RuleFor(x => x.Age)
.GreaterThan(0);
RuleFor(x => x)
.Must(x => x.Email!.EndsWith("@company.com"))
.When(x => x.Age >= 18)
.WithMessage("Users aged 18+ must use a corporate email address");
Related Objects and Collections
Use SetValidator for nested model validation and RuleForEach for collection elements.
public class AddressDto { public string? City { get; set; } public string? Zip { get; set; } }
public class AddressValidator : AbstractValidator<AddressDto>
{
public AddressValidator()
{
RuleFor(x => x.City).NotEmpty();
RuleFor(x => x.Zip).NotEmpty().Length(5, 10);
}
}
public class CustomerDto
{
public string? Name { get; set; }
public AddressDto? Address { get; set; }
public List<string> Phones { get; set; } = new();
}
public class CustomerValidator : AbstractValidator<CustomerDto>
{
public CustomerValidator()
{
RuleFor(x => x.Name).NotEmpty();
RuleFor(x => x.Address)
.NotNull().WithMessage("Address is required")
.SetValidator(new AddressValidator());
RuleForEach(x => x.Phones)
.NotEmpty().Matches(@"^\+?\d{7,15}$")
.WithMessage("Invalid phone number");
}
}
Custom and Asynchronous Rules
Use Must/MustAsync for complex or service-dependent validations.
RuleFor(x => x.Email)
.MustAsync(async (email, ct) =>
{
// Example: uniqueness check (pseudo repository)
bool exists = await _userRepository.ExistsByEmailAsync(email, ct);
return !exists;
})
.WithMessage("Email already exists");
Scenario-Based Validation with RuleSet
You can group rules for different scenarios (e.g., Create, Update).
public class ProductDto { public string? Name { get; set; } public decimal Price { get; set; } }
public class ProductValidator : AbstractValidator<ProductDto>
{
public ProductValidator()
{
RuleSet("Create", () =>
{
RuleFor(x => x.Name).NotEmpty();
RuleFor(x => x.Price).GreaterThan(0);
});
RuleSet("Update", () =>
{
RuleFor(x => x.Price).GreaterThanOrEqualTo(0);
});
}
}
// Usage:
// validator.Validate(model, options: o => o.IncludeRuleSets("Create"));
Customizing and Localizing Error Messages
- Use
WithName("Field Name")to display a user-friendly field name. - Enable multilingual support with resource (.resx) files.
- For global messages, you can customize the LanguageManager.
RuleFor(x => x.Email)
.WithName("Email")
.NotEmpty().WithMessage("{PropertyName} cannot be empty")
.EmailAddress().WithMessage("{PropertyName} is not valid");
ASP.NET Core Integration
Register services to enable automatic model validation and integrate with ModelState.
// Program.cs (Minimal API / ASP.NET Core 7+)
using FluentValidation;
using FluentValidation.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddFluentValidationAutoValidation(); // Works automatically with MVC model binding
builder.Services.AddValidatorsFromAssemblyContaining<UserDtoValidator>(); // Scans all validators
var app = builder.Build();
app.MapControllers();
app.Run();
In a controller, you don’t need to call the validator manually; after model binding, FluentValidation runs automatically and writes errors to ModelState.
Advanced Topics: Dependencies, Injection, and Reusable Rules
- Inject services/repositories into the validator constructor to use inside MustAsync.
- Share repeated rules with
Include:
public class NameRules : AbstractValidator<UserDto>
{
public NameRules()
=> RuleFor(x => x.Name).NotEmpty().MaximumLength(50);
}
public class UserDtoValidator2 : AbstractValidator<UserDto>
{
public UserDtoValidator2()
{
Include(new NameRules());
RuleFor(x => x.Email).EmailAddress();
}
}
Testability: FluentValidation.TestHelper
Use helper extensions to quickly test your validators.
dotnet add package FluentValidation.TestHelper
using FluentValidation.TestHelper;
using Xunit;
public class UserDtoValidatorTests
{
[Fact]
public void Name_Should_Not_Be_Empty()
{
var v = new UserDtoValidator();
var result = v.TestValidate(new UserDto { Name = "" });
result.ShouldHaveValidationErrorFor(x => x.Name);
}
[Fact]
public void Email_Should_Be_Valid()
{
var v = new UserDtoValidator();
var result = v.TestValidate(new UserDto { Email = "x" });
result.ShouldHaveValidationErrorFor(x => x.Email);
}
}
Real-World Example: Registration Form + Address & Phones
The following example demonstrates user registration, nested address validation, and phone list validation all together.
public class RegisterRequest
{
public string? FullName { get; set; }
public string? Email { get; set; }
public AddressDto? Address { get; set; }
public List<string> Phones { get; set; } = new();
}
public class RegisterValidator : AbstractValidator<RegisterRequest>
{
public RegisterValidator(IEmailBlacklistService blacklist)
{
RuleFor(x => x.FullName)
.NotEmpty().WithMessage("Full name is required")
.MaximumLength(80);
RuleFor(x => x.Email)
.NotEmpty().EmailAddress()
.MustAsync(async (mail, ct) => !await blacklist.IsBlacklistedAsync(mail, ct))
.WithMessage("Email is not accepted");
RuleFor(x => x.Address)
.NotNull().SetValidator(new AddressValidator());
RuleForEach(x => x.Phones)
.NotEmpty()
.Matches(@"^\+?\d{7,15}$").WithMessage("Invalid phone format");
// Conditional: If address country is 'TR', postal code must be 5 digits
When(x => x.Address?.Country == "TR", () =>
{
RuleFor(x => x.Address!.Zip)
.Matches(@"^\d{5}$").WithMessage("Postal code must be 5 digits for TR");
});
}
}
Performance and Best Practices
- Write validators as stateless; inject dependencies only in the constructor.
- In high-traffic environments, reduce IO-heavy MustAsync calls using caching.
- Use
RuleSetfor scenario-based validation in large models. - Keep error messages short and user-friendly; customize field names with
WithName. - Consider adding client-side validation when necessary, in addition to server-side checks.
TL;DR
- FluentValidation separates rules from models, providing readable and testable validation.
RuleFor,RuleForEach, andSetValidatorare the core building blocks.- Use When/Unless for conditional rules; RuleSet for scenario-based validation.
- Must/MustAsync is ideal for custom or external service checks.
- In ASP.NET Core, enable automatic integration with
AddFluentValidationAutoValidation(). - Secure your validators with unit tests using TestHelper.
Related Articles
Class, Object, Property and Methods in C#
Learn how classes, objects, properties, and methods work in C# and form the core building blocks of object-oriented programming.
Dependency Injection Basics in C#
Learn the basics of Dependency Injection in C#, including managing dependencies, loose coupling, and improving testability.