FluentValidation — Setup and Basic Rules

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."
Note: FluentValidation with 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.
Tip: Name validators using the convention {RequestType}ValidatorCreatePostRequestValidator 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.
Warning: When you add 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.

🧠 Test Yourself

Why is RuleFor(x => x.Slug).Matches(@"^[a-z0-9-]+$") more maintainable than [RegularExpression(@"^[a-z0-9-]+$")] as a DataAnnotation?