Advanced FluentValidation — Cross-Property Rules and Async Validators

Production APIs need validation rules that DataAnnotations cannot express: “end date must be after start date,” “if category is Premium then price is required,” “email must not already be registered,” or “each tag in the list must be unique.” FluentValidation’s advanced features — When(), RuleForEach(), MustAsync(), and nested validators — handle all of these cleanly while keeping the rules readable and testable.

Conditional, Cross-Property and Async Rules

public class CreateEventRequestValidator : AbstractValidator<CreateEventRequest>
{
    private readonly IEventRepository _repo;

    public CreateEventRequestValidator(IEventRepository repo)
    {
        _repo = repo;

        // ── Conditional rules with When() ──────────────────────────────────
        RuleFor(x => x.Price)
            .GreaterThan(0)
            .WithMessage("Price must be greater than zero.")
            .When(x => x.IsPaid);   // only validate Price when IsPaid == true

        RuleFor(x => x.MaxAttendees)
            .NotNull().WithMessage("Max attendees required for registration events.")
            .GreaterThan(0)
            .When(x => x.RequiresRegistration);

        // ── Cross-property rule ────────────────────────────────────────────
        RuleFor(x => x.EndDate)
            .GreaterThan(x => x.StartDate)
            .WithMessage("End date must be after start date.")
            .When(x => x.StartDate != default);

        RuleFor(x => x.StartDate)
            .GreaterThan(DateTime.UtcNow.Date)
            .WithMessage("Start date must be in the future.");

        // ── Validate collection elements with RuleForEach ─────────────────
        RuleForEach(x => x.SpeakerEmails)
            .EmailAddress()
            .WithMessage("Each speaker email must be a valid email address.");

        // ── Custom predicate with Must() ───────────────────────────────────
        RuleFor(x => x.Tags)
            .Must(tags => tags.Distinct().Count() == tags.Count)
            .WithMessage("Tags must be unique — no duplicates allowed.");

        // ── Async validator — database uniqueness check ────────────────────
        RuleFor(x => x.Title)
            .NotEmpty()
            .MaximumLength(200)
            .MustAsync(async (title, ct) =>
                !await _repo.TitleExistsAsync(title, ct))
            .WithMessage("An event with this title already exists.");
    }
}
Note: MustAsync() validators run during model binding before the action body, so the database check happens automatically as part of the FluentValidation pipeline. If the title exists, the request is rejected with a 400 ValidationProblemDetails without the action method running. This is different from manually calling the repository in the action body — the async rule is co-located with all other validation rules for the request type, making the full validation contract visible in one place.
Tip: Use WithErrorCode() alongside WithMessage() to attach machine-readable error codes to validation failures. Angular clients can use the error code to display specific UI: RuleFor(x => x.Email).MustAsync(UniqueEmail).WithMessage("Email already registered.").WithErrorCode("EMAIL_TAKEN"). The Angular interceptor reads the error code and can route to a “Login instead?” suggestion for this specific case. Error codes are included in the ValidationProblemDetails extensions when using FluentValidation.
Warning: Async validators (MustAsync()) execute a database query for every request — even valid ones that would succeed immediately. For high-traffic endpoints, uniqueness check validators can add latency. Consider caching frequently-checked values in memory (Redis or IMemoryCache) with a short TTL. Also, async validators run one at a time by default in FluentValidation — if you have multiple async rules, they are not parallelised. Configure EnableValidatorCaching = true in registration to cache compiled validators.

Nested DTO Validation with SetValidator

// ── Validator for nested DTO ───────────────────────────────────────────────
public class AddressDtoValidator : AbstractValidator<AddressDto>
{
    public AddressDtoValidator()
    {
        RuleFor(x => x.Street).NotEmpty().MaximumLength(200);
        RuleFor(x => x.City).NotEmpty().MaximumLength(100);
        RuleFor(x => x.Country).NotEmpty().Length(2, 2)
            .WithMessage("Country must be a 2-letter ISO code.");
        RuleFor(x => x.PostalCode)
            .Matches(@"^\d{5}(-\d{4})?$").When(x => x.Country == "US")
            .WithMessage("US postal codes must be in format 12345 or 12345-6789.");
    }
}

// ── Compose nested validators in parent ───────────────────────────────────
public class CreateUserRequestValidator : AbstractValidator<CreateUserRequest>
{
    public CreateUserRequestValidator()
    {
        RuleFor(x => x.Email).NotEmpty().EmailAddress();
        RuleFor(x => x.Password).NotEmpty().MinimumLength(8);

        // Compose nested validator — validates Address using AddressDtoValidator
        RuleFor(x => x.Address)
            .NotNull()
            .SetValidator(new AddressDtoValidator());
        // Errors: "address.city", "address.country", etc. — dotted path in errors dict
    }
}

Unit Testing Validators

// ── FluentValidation validators are easy to unit test ─────────────────────
public class CreatePostRequestValidatorTests
{
    private readonly CreatePostRequestValidator _sut = new();

    [Fact]
    public void Title_TooShort_HasError()
    {
        var request = new CreatePostRequest { Title = "ab", Body = "x".PadRight(50), Slug = "test" };
        var result  = _sut.TestValidate(request);
        result.ShouldHaveValidationErrorFor(x => x.Title)
              .WithErrorMessage("Title must be at least 5 characters.");
    }

    [Fact]
    public void ValidRequest_HasNoErrors()
    {
        var request = new CreatePostRequest
        {
            Title = "A Valid Title",
            Body  = "A".PadRight(50),
            Slug  = "a-valid-slug",
        };
        _sut.TestValidate(request).ShouldNotHaveAnyValidationErrors();
    }
}

Common Mistakes

Mistake 1 — Using Must() for async operations (not awaited, always passes or deadlocks)

❌ Wrong — sync Must() with async database call:

RuleFor(x => x.Slug).Must(slug => !_repo.SlugExistsAsync(slug).Result);  // deadlock!

✅ Correct — use MustAsync(async (slug, ct) => !await _repo.SlugExistsAsync(slug, ct)).

Mistake 2 — Forgetting to add When() for conditional rules (irrelevant errors on valid requests)

❌ Wrong — Price validation runs even when IsPaid = false; free events fail “Price must be > 0”.

✅ Correct — always scope conditional rules with .When(x => x.IsPaid).

🧠 Test Yourself

A validator uses RuleForEach(x => x.Tags).Must(tag => tag.Length <= 50). The request has Tags = [“dotnet”, “A 60-character-long tag name that exceeds the limit”]. What error key appears in the ValidationProblemDetails errors dictionary?