.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.DependencyInjectionExtensionsC#
// 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
MustAsyncfor DB-backed uniqueness checks; register validators as Scoped to support repository injection RuleForEachhandles collections;ChildRules/SetValidatorhandle nested objects- Auto-validation keeps controllers thin; explicit validation gives you more control over error responses