FluentValidation is a library for building strongly-typed validation rules in C# using a fluent API. It is more expressive than DataAnnotations for complex validation: you can read the rules as plain English, conditional rules are straightforward, async validators are first-class, and error messages are easy to customise with dynamic placeholders. It integrates with ASP.NET Core’s [ApiController] to produce the same ValidationProblemDetails response format — the Angular client sees no difference.
FluentValidation Setup
// dotnet add package FluentValidation.AspNetCore
// ── Program.cs — register FluentValidation ────────────────────────────────
builder.Services
.AddControllers()
.AddFluentValidationAutoValidation() // replaces [ApiController] manual validation
.AddFluentValidationClientsideAdapters(); // optional: generate client-side rules
// Register all validators from the assembly
builder.Services.AddValidatorsFromAssemblyContaining<CreatePostRequestValidator>();
// ── Validator class ────────────────────────────────────────────────────────
public class CreatePostRequestValidator : AbstractValidator<CreatePostRequest>
{
public CreatePostRequestValidator()
{
RuleFor(x => x.Title)
.NotEmpty().WithMessage("Title is required.")
.MinimumLength(5).WithMessage("Title must be at least 5 characters.")
.MaximumLength(200).WithMessage("Title must not exceed 200 characters.");
RuleFor(x => x.Body)
.NotEmpty().WithMessage("Body content is required.")
.MinimumLength(50).WithMessage("Body must be at least 50 characters.");
RuleFor(x => x.Slug)
.NotEmpty().WithMessage("Slug is required.")
.MaximumLength(100)
.Matches(@"^[a-z0-9-]+$")
.WithMessage("Slug must contain only lowercase letters, numbers, and hyphens.");
RuleFor(x => x.Tags)
.Must(t => t.Count <= 10)
.WithMessage("A post can have at most 10 tags.");
// Must() — simple custom predicate
RuleFor(x => x.Excerpt)
.MaximumLength(250)
.Must(e => e is null || !e.Contains("<script"))
.WithMessage("Excerpt contains invalid content.");
}
}
// ── Readable validation rules summary ─────────────────────────────────────
// "Title is required, 5–200 characters."
// "Body is required, at least 50 characters."
// "Slug is required, lowercase alphanumeric with hyphens, max 100 chars."
// "Tags: max 10 allowed."
// "Excerpt: max 250 chars, no script tags."
AddFluentValidationAutoValidation() integrates with [ApiController]‘s ModelState pipeline. The validator runs during model binding, adds any failures to ModelState, and the auto-validation behaviour returns a 400 ValidationProblemDetails response exactly as DataAnnotations would. From Angular’s perspective, there is no difference — the same error format is returned. FluentValidation does not replace DataAnnotations entirely — you can keep format-level DataAnnotations on DTOs and add FluentValidation on top for complex rules.{RequestType}Validator — CreatePostRequestValidator for CreatePostRequest. Register all validators at once with AddValidatorsFromAssemblyContaining<T>() so you never forget to register a new validator. Validators are registered as Scoped by default, which is correct — they can inject Scoped services (like DbContext) for async rules. Singleton validators cannot inject Scoped services.AddFluentValidationAutoValidation(), DataAnnotations validation is disabled by default. Any [Required], [StringLength], and other DataAnnotations on your request DTOs stop running. You must migrate all validation logic to FluentValidation, or keep both by configuring DisableDataAnnotationsValidation = false. Silently losing DataAnnotations validation is a common FluentValidation setup mistake — verify by sending an invalid request after setup and checking which errors appear.FluentValidation vs DataAnnotations
| Concern | DataAnnotations | FluentValidation |
|---|---|---|
| Simple required/length | ✅ Concise attributes | More verbose but works |
| Conditional rules | Complex/awkward | ✅ When()/Unless() clean |
| Cross-property rules | IValidatableObject | ✅ Must() with access to root |
| Async rules (DB check) | Not supported | ✅ MustAsync() first-class |
| Nested DTO validation | Manual recursion | ✅ SetValidator() clean |
| Testability | Hard to unit-test | ✅ Easily unit-testable |
| Dynamic error messages | Limited | ✅ {PropertyValue} placeholders |
Common Mistakes
Mistake 1 — DataAnnotations silently disabled after AddFluentValidationAutoValidation()
❌ Wrong — [Required] stops running; invalid requests now reach action bodies.
✅ Correct — migrate DataAnnotations to FluentValidation rules, or verify both are running with a test.
Mistake 2 — Not registering the validator (no validation runs, action always succeeds)
❌ Wrong — new validator class added but not registered; requests bypass validation silently.
✅ Correct — use AddValidatorsFromAssemblyContaining<T>() to auto-register all validators.