dotnet-cqrs/docs/core-features/validation/fluentvalidation-setup.md

11 KiB

FluentValidation Setup

How to install, configure, and use FluentValidation with Svrnty.CQRS.

Installation

Install NuGet Package

dotnet add package FluentValidation
dotnet add package FluentValidation.DependencyInjectionExtensions

Package Reference

<ItemGroup>
  <PackageReference Include="FluentValidation" Version="11.11.0" />
  <PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
</ItemGroup>

Creating Validators

Validators inherit from AbstractValidator<T> and define rules in the constructor.

Basic Validator

using FluentValidation;

public class CreateUserCommand
{
    public string Name { get; init; } = string.Empty;
    public string Email { get; init; } = string.Empty;
    public int Age { get; init; }
}

public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
{
    public CreateUserCommandValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty()
            .WithMessage("Name is required")
            .MaximumLength(100)
            .WithMessage("Name must not exceed 100 characters");

        RuleFor(x => x.Email)
            .NotEmpty()
            .EmailAddress()
            .WithMessage("Valid email address is required");

        RuleFor(x => x.Age)
            .GreaterThanOrEqualTo(18)
            .WithMessage("User must be at least 18 years old")
            .LessThanOrEqualTo(150)
            .WithMessage("Age must be realistic");
    }
}

Validator with Dependencies

public class CreateProductCommandValidator : AbstractValidator<CreateProductCommand>
{
    private readonly IProductRepository _productRepository;

    public CreateProductCommandValidator(IProductRepository productRepository)
    {
        _productRepository = productRepository;

        RuleFor(x => x.Name)
            .NotEmpty()
            .MustAsync(BeUniqueName)
            .WithMessage("Product name already exists");

        RuleFor(x => x.Price)
            .GreaterThan(0)
            .WithMessage("Price must be greater than zero");

        RuleFor(x => x.CategoryId)
            .NotEmpty()
            .WithMessage("Category is required");
    }

    private async Task<bool> BeUniqueName(string name, CancellationToken cancellationToken)
    {
        var existing = await _productRepository.GetByNameAsync(name, cancellationToken);
        return existing == null;
    }
}

Registering Validators

Individual Registration

builder.Services.AddTransient<IValidator<CreateUserCommand>, CreateUserCommandValidator>();

Automatic Registration

Register all validators in an assembly:

using FluentValidation;

builder.Services.AddValidatorsFromAssemblyContaining<CreateUserCommandValidator>();

Registration with Command

// Register command
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();

// Register validator
builder.Services.AddTransient<IValidator<CreateUserCommand>, CreateUserCommandValidator>();

Bulk Registration Extension

public static class ValidationRegistration
{
    public static IServiceCollection AddCommandValidators(this IServiceCollection services)
    {
        // Register all validators
        services.AddValidatorsFromAssemblyContaining<CreateUserCommandValidator>();

        return services;
    }
}

// Usage
builder.Services.AddCommandValidators();

Common Validation Rules

Required Fields

// Not null or empty
RuleFor(x => x.Name)
    .NotEmpty();

// Not null
RuleFor(x => x.UserId)
    .NotNull();

String Validation

// Length
RuleFor(x => x.Username)
    .MinimumLength(3)
    .MaximumLength(20);

// Exact length
RuleFor(x => x.PostalCode)
    .Length(5);

// Email
RuleFor(x => x.Email)
    .EmailAddress();

// Regular expression
RuleFor(x => x.PhoneNumber)
    .Matches(@"^\+?[1-9]\d{1,14}$")
    .WithMessage("Invalid phone number format");

Numeric Validation

// Greater than
RuleFor(x => x.Age)
    .GreaterThan(0);

// Greater than or equal
RuleFor(x => x.Quantity)
    .GreaterThanOrEqualTo(1);

// Less than
RuleFor(x => x.Discount)
    .LessThan(100);

// Range
RuleFor(x => x.Rating)
    .InclusiveBetween(1, 5);

Collection Validation

// Not empty collection
RuleFor(x => x.Items)
    .NotEmpty()
    .WithMessage("At least one item is required");

// Collection count
RuleFor(x => x.Tags)
    .Must(tags => tags.Count <= 10)
    .WithMessage("Maximum 10 tags allowed");

// Validate each item
RuleForEach(x => x.Items)
    .SetValidator(new OrderItemValidator());

Complex Object Validation

// Nested object
RuleFor(x => x.Address)
    .NotNull()
    .SetValidator(new AddressValidator());

// Conditional validation
RuleFor(x => x.CompanyName)
    .NotEmpty()
    .When(x => x.IsCompany);

Custom Predicates

// Must satisfy predicate
RuleFor(x => x.StartDate)
    .Must(date => date > DateTime.UtcNow)
    .WithMessage("Start date must be in the future");

// Must satisfy async predicate
RuleFor(x => x.Email)
    .MustAsync(async (email, cancellationToken) =>
    {
        var exists = await _userRepository.EmailExistsAsync(email, cancellationToken);
        return !exists;
    })
    .WithMessage("Email is already registered");

Conditional Validation

When

RuleFor(x => x.CompanyName)
    .NotEmpty()
    .When(x => x.IsCompany);

RuleFor(x => x.TaxId)
    .NotEmpty()
    .When(x => x.IsCompany);

Unless

RuleFor(x => x.MiddleName)
    .NotEmpty()
    .Unless(x => x.PreferNoMiddleName);

Cross-Property Validation

public class DateRangeValidator : AbstractValidator<SearchQuery>
{
    public DateRangeValidator()
    {
        RuleFor(x => x.EndDate)
            .GreaterThanOrEqualTo(x => x.StartDate)
            .WithMessage("End date must be after start date");

        RuleFor(x => x)
            .Must(q => (q.EndDate - q.StartDate).TotalDays <= 90)
            .WithMessage("Date range must not exceed 90 days");
    }
}

Async Validation

Database Lookup

public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
{
    private readonly IUserRepository _userRepository;

    public CreateUserCommandValidator(IUserRepository userRepository)
    {
        _userRepository = userRepository;

        RuleFor(x => x.Email)
            .MustAsync(BeUniqueEmail)
            .WithMessage("Email is already registered");
    }

    private async Task<bool> BeUniqueEmail(string email, CancellationToken cancellationToken)
    {
        var user = await _userRepository.GetByEmailAsync(email, cancellationToken);
        return user == null;
    }
}

External API Call

public class ValidateAddressCommandValidator : AbstractValidator<ValidateAddressCommand>
{
    private readonly IAddressValidationService _validationService;

    public ValidateAddressCommandValidator(IAddressValidationService validationService)
    {
        _validationService = validationService;

        RuleFor(x => x.ZipCode)
            .MustAsync(BeValidZipCode)
            .WithMessage("Invalid zip code");
    }

    private async Task<bool> BeValidZipCode(string zipCode, CancellationToken cancellationToken)
    {
        return await _validationService.IsValidZipCodeAsync(zipCode, cancellationToken);
    }
}

Validation Messages

Custom Messages

RuleFor(x => x.Name)
    .NotEmpty()
    .WithMessage("Name is required");

RuleFor(x => x.Age)
    .GreaterThanOrEqualTo(18)
    .WithMessage("You must be at least 18 years old to register");

Placeholder Messages

RuleFor(x => x.Name)
    .MaximumLength(100)
    .WithMessage("Name must not exceed {MaxLength} characters. You entered {TotalLength} characters.");

RuleFor(x => x.Quantity)
    .InclusiveBetween(1, 100)
    .WithMessage("Quantity must be between {From} and {To}. You entered {PropertyValue}.");

Property Name Overrides

RuleFor(x => x.EmailAddress)
    .NotEmpty()
    .WithName("Email");
// Error: "Email is required" (instead of "Email Address is required")

Rule Sets

public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
{
    public CreateUserCommandValidator()
    {
        // Default rules (always run)
        RuleFor(x => x.Name)
            .NotEmpty();

        // Rule set for creating
        RuleSet("Create", () =>
        {
            RuleFor(x => x.Email)
                .MustAsync(BeUniqueEmail);
        });

        // Rule set for updating
        RuleSet("Update", () =>
        {
            RuleFor(x => x.UserId)
                .GreaterThan(0);
        });
    }
}

// Validate with specific rule set
var result = await validator.ValidateAsync(command, options =>
{
    options.IncludeRuleSets("Create");
});

Testing Validators

using FluentValidation.TestHelper;
using Xunit;

public class CreateUserCommandValidatorTests
{
    private readonly CreateUserCommandValidator _validator;

    public CreateUserCommandValidatorTests()
    {
        _validator = new CreateUserCommandValidator();
    }

    [Fact]
    public void Should_Require_Name()
    {
        var command = new CreateUserCommand { Name = "" };
        var result = _validator.TestValidate(command);
        result.ShouldHaveValidationErrorFor(x => x.Name);
    }

    [Fact]
    public void Should_Require_Valid_Email()
    {
        var command = new CreateUserCommand { Email = "invalid" };
        var result = _validator.TestValidate(command);
        result.ShouldHaveValidationErrorFor(x => x.Email);
    }

    [Fact]
    public void Should_Reject_Under_Age()
    {
        var command = new CreateUserCommand { Age = 16 };
        var result = _validator.TestValidate(command);
        result.ShouldHaveValidationErrorFor(x => x.Age);
    }

    [Fact]
    public void Should_Accept_Valid_Command()
    {
        var command = new CreateUserCommand
        {
            Name = "John Doe",
            Email = "john@example.com",
            Age = 25
        };

        var result = _validator.TestValidate(command);
        result.ShouldNotHaveAnyValidationErrors();
    }
}

Best Practices

DO

  • Create one validator per command/query
  • Use descriptive error messages
  • Keep validators focused and single-purpose
  • Use async validation for I/O operations
  • Test validators independently
  • Register validators with DI
  • Use rule sets for complex scenarios

DON'T

  • Don't perform business logic in validators
  • Don't modify state in validators
  • Don't throw exceptions (return validation results)
  • Don't validate domain entities (validate DTOs/commands)
  • Don't skip async validation when needed
  • Don't create validators without tests

See Also