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.
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.
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().
[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:
// Program.cs
builder.Services.AddValidatorsFromAssemblyContaining<CreateOrderValidator>();
builder.Services.AddScoped<ValidationFilter>();Then create a filter that runs validation and returns a 400 if anything fails:
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:
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 handlersUse
When(),Unless(), andCustom()to express conditional and cross-field rules in a readable wayUnit test validators directly without HTTP—instantiate, call
ValidateAsync(), assert on the resultIntegrate 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






Comments
Loading comments…