Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 009 / 100

Validation with FluentValidation: a practical .NET guide

Data annotations get messy fast. FluentValidation keeps rules readable and testable.

Expona Admin7 min read

Validation with FluentValidation

Data annotations scattered across your domain models become unmaintainable the moment you have more than a handful of rules. I've watched projects where validation logic lives in three different places—attributes on the model, business logic in handlers, and emergency checks in controllers—only to find contradictions when requirements change. FluentValidation keeps your rules organized, testable, and readable by consolidating them into dedicated validator classes that read like documentation.

Why Data Annotations Fall Short

Data annotations work fine for trivial cases: a required field, a maximum string length. But real applications have conditional rules, cross-field dependencies, and business logic that attributes can't express cleanly.

Consider an e-commerce order. You might require:

  • Items list must not be empty

  • Shipping address is required only if not a digital product

  • Discount code is valid only for specific regions

  • Customer must have no unpaid invoices before checkout

With attributes, you'd scatter [Required] decorators, add custom validation attributes, and still end up with business checks buried in your handler. FluentValidation centralizes all of this into a single, readable class that's trivial to unit test.

Rules Live in One Class per Request

The core idea is simple: create a validator class for each request DTO, inherit from AbstractValidator<T>, and define all your rules in the constructor. Each rule is a fluent chain that reads almost like English.

csharp
public class CreateOrderRequest
{
    public List<OrderItem> Items { get; set; }
    public string Email { get; set; }
    public string ShippingCity { get; set; }
    public bool IsDigitalOnly { get; set; }
}

public class CreateOrderValidator : AbstractValidator<CreateOrderRequest>
{
    public CreateOrderValidator()
    {
        RuleFor(x => x.Items)
            .NotEmpty()
            .WithMessage("Order must contain at least one item");

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

        RuleFor(x => x.ShippingCity)
            .NotEmpty()
            .When(x => !x.IsDigitalOnly)
            .WithMessage("Shipping city required for physical orders");
    }
}

Notice that your rules are now centralized, named consistently, and easy to modify without touching your request class. Each validator is responsible for exactly one request type, making it obvious where to look when rules change.

Conditional and Cross-Field Rules Stay Clean

Real-world validation often depends on context. FluentValidation handles this with When(), Unless(), and Custom() methods that keep your logic readable.

csharp
public class UpdateUserProfileValidator : AbstractValidator<UpdateUserProfile>
{
    private readonly IUserRepository _userRepository;

    public UpdateUserProfileValidator(IUserRepository userRepository)
    {
        _userRepository = userRepository;

        RuleFor(x => x.Email)
            .EmailAddress()
            .MustAsync(async (email, ct) =>
            {
                var exists = await _userRepository.EmailExistsAsync(email, ct);
                return !exists;
            })
            .WithMessage("Email already registered")
            .When(x => !string.IsNullOrEmpty(x.Email));

        RuleFor(x => x.DateOfBirth)
            .Must(dob => DateTime.Now.Year - dob.Year >= 18)
            .WithMessage("Must be at least 18 years old")
            .When(x => x.DateOfBirth.HasValue);

        RuleFor(x => x.PhoneNumber)
            .Matches(@"^\d{10}$")
            .WithMessage("Phone must be 10 digits")
            .When(x => !string.IsNullOrEmpty(x.PhoneNumber));

        RuleFor(x => x.PasswordConfirm)
            .Equal(x => x.Password)
            .WithMessage("Passwords do not match")
            .When(x => !string.IsNullOrEmpty(x.Password));
    }
}

The validator can depend on repositories or services, check async constraints like "email already exists," and express complex conditions without cluttering your handler. Compare that to embedding this logic in your endpoint—it's night and day.

Unit Test Validators Without HTTP

One of FluentValidation's biggest wins is testability. You don't need a running server, a test client, or mock HTTP contexts. Just instantiate the validator and call ValidateAsync().

csharp
[TestClass]
public class CreateOrderValidatorTests
{
    private CreateOrderValidator _validator;

    [TestInitialize]
    public void Setup()
    {
        _validator = new CreateOrderValidator();
    }

    [TestMethod]
    public async Task ShouldFailWhenItemsEmpty()
    {
        var request = new CreateOrderRequest
        {
            Items = new List<OrderItem>(),
            Email = "test@example.com",
            IsDigitalOnly = true
        };

        var result = await _validator.ValidateAsync(request);

        Assert.IsFalse(result.IsValid);
        Assert.IsTrue(result.Errors.Any(e => e.PropertyName == "Items"));
    }

    [TestMethod]
    public async Task ShouldPassWhenPhysicalOrderHasShippingCity()
    {
        var request = new CreateOrderRequest
        {
            Items = new List<OrderItem> { new OrderItem { Sku = "ABC", Qty = 1 } },
            Email = "test@example.com",
            ShippingCity = "Colombo",
            IsDigitalOnly = false
        };

        var result = await _validator.ValidateAsync(request);

        Assert.IsTrue(result.IsValid);
    }

    [TestMethod]
    public async Task ShouldFailWhenInvalidEmail()
    {
        var request = new CreateOrderRequest
        {
            Items = new List<OrderItem> { new OrderItem { Sku = "ABC", Qty = 1 } },
            Email = "not-an-email",
            IsDigitalOnly = true
        };

        var result = await _validator.ValidateAsync(request);

        Assert.IsFalse(result.IsValid);
        Assert.IsTrue(result.Errors.Any(e => e.PropertyName == "Email"));
    }
}

These tests run in milliseconds with zero infrastructure. You're testing validation logic in isolation, which means bugs in your validator are caught instantly and you catch regressions before they reach staging.

Plug Into an Endpoint Filter for Automatic 400s

To make validation actually run in your ASP.NET Core endpoints, integrate FluentValidation into the request pipeline. The simplest approach is an endpoint filter or middleware that validates before your handler runs.

First, register FluentValidation in your DI container:

csharp
// Program.cs
builder.Services.AddValidatorsFromAssemblyContaining<CreateOrderValidator>();
builder.Services.AddScoped<ValidationFilter>();

Then create a filter that runs validation and returns a 400 if anything fails:

csharp
public class ValidationFilter : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var validator = context.HttpContext.RequestServices
            .GetService(typeof(IValidator<>)
                .MakeGenericType(context.Arguments[0].GetType())) 
            as IValidator;

        if (validator == null)
            return await next(context);

        var validationResult = await validator.ValidateAsync(
            new ValidationContext<object>(context.Arguments[0]));

        if (!validationResult.IsValid)
        {
            return Results.BadRequest(new
            {
                errors = validationResult.Errors
                    .GroupBy(e => e.PropertyName)
                    .ToDictionary(
                        g => g.Key,
                        g => g.Select(e => e.ErrorMessage).ToArray())
            });
        }

        return await next(context);
    }
}

Now decorate your endpoints with the filter:

csharp
app.MapPost("/orders", CreateOrder)
    .AddEndpointFilter<ValidationFilter>();

async Task<IResult> CreateOrder(CreateOrderRequest request, IOrderService orderService)
{
    // Request is guaranteed valid here
    var order = await orderService.CreateAsync(request);
    return Results.Created($"/orders/{order.Id}", order);
}

Requests that fail validation immediately return a 400 with detailed error messages. Valid requests flow into your handler where you can assume the data is clean. This is the separation of concerns at work: validation logic is separate from business logic, both are testable, and your endpoints stay lean.

Common Mistakes

Putting Business Logic in Validators

Validators should check that data is well-formed—email format, required fields, length constraints. They should not check business rules like "customer has sufficient credit" or "product is in stock." Those checks belong in your handler or service layer where they can be tested independently and updated without touching your validation layer.

Forgetting to Inject Dependencies

Validators are singletons in your DI container by default, but they often need access to repositories or services. Make sure they're scoped properly and that injected dependencies are also available at the scope level. If a validator makes async database calls, register it with the correct lifetime.

Returning Unhelpful Error Messages

Default error messages like "Items is required" don't help the client. Use WithMessage() to explain what you actually want: "Order must contain at least one item." Your API users will thank you.

Testing Validators Only Through HTTP Tests

If you only test validators by hitting endpoints, you'll miss edge cases and spend time waiting for the server to start. Unit test your validators directly. It's faster, cleaner, and you catch bugs sooner.

Key Takeaways

  • Centralize all validation rules for a request DTO into a single AbstractValidator<T> class—no scattered attributes, no business logic in handlers

  • Use When(), Unless(), and Custom() to express conditional and cross-field rules in a readable way

  • Unit test validators directly without HTTP—instantiate, call ValidateAsync(), assert on the result

  • Integrate with an endpoint filter to make validation automatic and return 400s with structured error details

  • Keep validators responsible for data shape only; push business logic into your service or handler layer

  • Inject dependencies into validators when you need async checks like "email already exists"

  • Write clear, user-facing error messages so API clients know exactly what went wrong

ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles