.NET & C# Development · Lesson 43 of 92

Validate Requests With FluentValidation — Zero Boilerplate

Why Data Annotations Don't Scale

C#
// Data annotations: rules mixed with the model, no reuse, no async
public class CreateOrderRequest
{
    [Required]
    [StringLength(200, MinimumLength = 1)]
    public string ProductName { get; set; } = string.Empty;

    [Range(1, 10000)]
    public int Quantity { get; set; }

    // How do you validate that the CustomerId exists in the DB?
    // How do you express "if PaymentMethod is Card, CardNumber is required"?
    // You can't — you write it in the controller instead.
}

FluentValidation puts rules in their own class, keeps DTOs clean, and handles anything from regex to async DB lookups.

Setup

Bash
dotnet add package FluentValidation.DependencyInjectionExtensions
C#
// Program.cs — registers all validators in the assembly
builder.Services.AddValidatorsFromAssemblyContaining<Program>();

Basic Validator

C#
using FluentValidation;

public class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
    public CreateOrderRequestValidator()
    {
        RuleFor(x => x.ProductName)
            .NotEmpty().WithMessage("Product name is required.")
            .Length(1, 200).WithMessage("Product name must be 1–200 characters.");

        RuleFor(x => x.Quantity)
            .GreaterThan(0).WithMessage("Quantity must be at least 1.")
            .LessThanOrEqualTo(10_000).WithMessage("Quantity cannot exceed 10,000.");

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

        RuleFor(x => x.ShippingPostalCode)
            .NotEmpty()
            .Matches(@"^\d{5}(-\d{4})?$").WithMessage("Postal code must be 5 or 9 digits.");
    }
}

Conditional Rules

C#
public class PaymentRequestValidator : AbstractValidator<PaymentRequest>
{
    public PaymentRequestValidator()
    {
        RuleFor(x => x.PaymentMethod)
            .NotEmpty()
            .IsInEnum();

        // CardNumber only required when method is Card
        RuleFor(x => x.CardNumber)
            .NotEmpty()
            .CreditCard()
            .When(x => x.PaymentMethod == PaymentMethod.Card);

        // BankAccountNumber only required for BankTransfer
        RuleFor(x => x.BankAccountNumber)
            .NotEmpty()
            .Matches(@"^\d{8,12}$")
            .When(x => x.PaymentMethod == PaymentMethod.BankTransfer);
    }
}

Nested Object Validation (ChildRules)

C#
public class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
    public CreateOrderRequestValidator()
    {
        RuleFor(x => x.ProductName).NotEmpty();

        // Validate a nested object inline
        RuleFor(x => x.ShippingAddress)
            .NotNull().WithMessage("Shipping address is required.")
            .ChildRules(address =>
            {
                address.RuleFor(a => a.Street).NotEmpty();
                address.RuleFor(a => a.City).NotEmpty();
                address.RuleFor(a => a.PostalCode).NotEmpty().Matches(@"^\d{5}$");
                address.RuleFor(a => a.Country)
                    .NotEmpty()
                    .Length(2, 2).WithMessage("Country must be a 2-letter ISO code.");
            });
    }
}

Or create a separate validator for the nested type and set it as a child:

C#
public class AddressValidator : AbstractValidator<Address>
{
    public AddressValidator()
    {
        RuleFor(x => x.Street).NotEmpty();
        RuleFor(x => x.City).NotEmpty();
        RuleFor(x => x.PostalCode).NotEmpty();
    }
}

public class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
    public CreateOrderRequestValidator(AddressValidator addressValidator)
    {
        RuleFor(x => x.ShippingAddress)
            .NotNull()
            .SetValidator(addressValidator);
    }
}

Collection Validation (RuleForEach)

C#
public class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
    public CreateOrderRequestValidator()
    {
        RuleFor(x => x.LineItems)
            .NotEmpty().WithMessage("Order must have at least one item.");

        RuleForEach(x => x.LineItems).ChildRules(item =>
        {
            item.RuleFor(i => i.ProductId).GreaterThan(0);
            item.RuleFor(i => i.Quantity).InclusiveBetween(1, 999);
            item.RuleFor(i => i.UnitPrice).GreaterThan(0m);
        });
    }
}

Custom Validators With Must

C#
RuleFor(x => x.DeliveryDate)
    .Must(date => date > DateOnly.FromDateTime(DateTime.UtcNow))
    .WithMessage("Delivery date must be in the future.");

// Access the whole object in Must
RuleFor(x => x.EndDate)
    .Must((request, endDate) => endDate > request.StartDate)
    .WithMessage("End date must be after start date.");

Async Validation for DB Uniqueness

C#
public class RegisterUserRequestValidator : AbstractValidator<RegisterUserRequest>
{
    private readonly IUserRepository _users;

    public RegisterUserRequestValidator(IUserRepository users)
    {
        _users = users;

        RuleFor(x => x.Email)
            .NotEmpty()
            .EmailAddress()
            .MustAsync(BeUniqueEmailAsync)
            .WithMessage("This email address is already registered.");
    }

    private async Task<bool> BeUniqueEmailAsync(
        string email, CancellationToken ct)
    {
        return !await _users.ExistsAsync(email, ct);
    }
}

Register the validator with scoped lifetime when it depends on repositories:

C#
// AddValidatorsFromAssemblyContaining registers as Scoped by default — this is correct
builder.Services.AddValidatorsFromAssemblyContaining<Program>();

Auto-Validation in ASP.NET Core

Manually calling validator.ValidateAsync in every controller is noisy. FluentValidation can hook into the model binding pipeline:

C#
// dotnet add package FluentValidation.AspNetCore
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddValidatorsFromAssemblyContaining<Program>();

Now invalid requests automatically return 400 Bad Request with the full error list — before your action method runs.

If you prefer explicit control (recommended for non-trivial APIs):

C#
[HttpPost("orders")]
public async Task<IActionResult> Create(
    [FromBody] CreateOrderRequest request,
    [FromServices] IValidator<CreateOrderRequest> validator)
{
    var validation = await validator.ValidateAsync(request);
    if (!validation.IsValid)
        return BadRequest(validation.ToDictionary()); // groups errors by property name

    var result = await _orderService.CreateAsync(request);
    return result.Match(
        order  => CreatedAtAction(nameof(Get), new { id = order.Id }, order),
        errors => errors.ToActionResult(this));
}

Testing Validators

C#
public class CreateOrderRequestValidatorTests
{
    private readonly CreateOrderRequestValidator _validator = new();

    [Fact]
    public async Task Fails_WhenProductNameIsEmpty()
    {
        var request = new CreateOrderRequest { ProductName = "", Quantity = 1 };
        var result = await _validator.TestValidateAsync(request);
        result.ShouldHaveValidationErrorFor(x => x.ProductName);
    }

    [Fact]
    public async Task Passes_WithValidRequest()
    {
        var request = new CreateOrderRequest
        {
            ProductName = "Widget",
            Quantity    = 5,
            Email       = "user@example.com"
        };
        var result = await _validator.TestValidateAsync(request);
        result.ShouldNotHaveAnyValidationErrors();
    }
}

TestValidate / TestValidateAsync are helpers from the FluentValidation.TestHelper package.

Key Takeaways

  • Data annotations are fine for simple DTOs; FluentValidation scales to complex rules
  • Keep validators in their own classes — they're testable, injectable, and composable
  • Use MustAsync for DB-backed uniqueness checks; register validators as Scoped to support repository injection
  • RuleForEach handles collections; ChildRules / SetValidator handle nested objects
  • Auto-validation keeps controllers thin; explicit validation gives you more control over error responses