Advanced Model Validation (FluentValidation)

Why Beyond Data Annotations?

While [Required] and [StringLength] are convenient, they fall short in professional enterprise applications due to:

  1. Logic Leakage: Business rules (e.g., “Field A depends on Field B”) are hardcoded into your data transfer objects (DTOs).
  2. Lack of Dependency Injection: You can’t easily perform a database check (e.g., “Is this email unique?”) inside a Data Annotation.
  3. No Async Support: Data Annotations only support synchronous validation.

The Solution: FluentValidation

FluentValidation is a library for .NET that uses a Fluent Interface and lambda expressions for building strongly-typed validation rules.


Core Concepts

1. Decoupled Logic

Validation rules live in a separate AbstractValidator<T> class, keeping your DTOs (POCOs) clean.

2. Complex & Conditional Rules

You can easily chain rules or apply them conditionally using .When() or .Unless().

3. Asynchronous Validation

Use .MustAsync() to perform I/O-bound checks (like database lookups) during the validation pipeline.


Technical Implementation

1. The Validator Class

public class RegisterUserRequestValidator : AbstractValidator<RegisterUserRequest>
{
    private readonly IUserRepository _repo;

    public RegisterUserRequestValidator(IUserRepository repo)
    {
        _repo = repo;

        RuleFor(x => x.Email)
            .NotEmpty().EmailAddress()
            .MustAsync(BeUniqueEmail).WithMessage("Email already in use.");

        RuleFor(x => x.Password)
            .MinimumLength(8)
            .Matches("[A-Z]").WithMessage("Must contain an uppercase letter.");

        // Conditional Validation
        RuleFor(x => x.CompanyName)
            .NotEmpty()
            .When(x => x.IsBusinessAccount);
    }

    private async Task<bool> BeUniqueEmail(string email, CancellationToken token)
    {
        return !await _repo.ExistsAsync(email);
    }
}

Practice Exercise

Implement a validator where the EndDate must be after the StartDate. Then, register it in a modern .NET 8 Program.cs.


Answer

1. The Validator Logic

public class PeriodValidator : AbstractValidator<PeriodModel>
{
    public PeriodValidator()
    {
        RuleFor(x => x.StartDate).NotEmpty();

        RuleFor(x => x.EndDate)
            .NotEmpty()
            .GreaterThan(x => x.StartDate)
            .WithMessage("End date must be after the start date.");
    }
}

2. Registration in Program.cs

using FluentValidation;
using FluentValidation.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

// Register all validators in the assembly automatically
builder.Services.AddValidatorsFromAssemblyContaining<PeriodValidator>();

// Integration with ASP.NET Core MVC/API
builder.Services.AddFluentValidationAutoValidation();

Why This Works

  1. Single Responsibility Principle: The model defines the Data; the validator defines the Rules.
  2. Testability: You can write unit tests for your validator logic without needing to start a web server or mock the ModelState.
  3. Clarity: The “Fluent” syntax reads like a sentence, making the business requirements obvious to anyone reading the code.

Summary

FluentValidation is the gold standard for .NET applications. By separating validation from data, you create a system that is easier to maintain, faster to test, and capable of handling complex business rules that Data Annotations simply cannot represent.