Standalone FluentValidation patterns — validators, DI registration, manual and auto validation, custom validators, async validators, and integration with ProblemDetails. Trigger: FluentValidation, validator, validation, AbstractValidator, RuleFor.
From dotnet-ai-kitnpx claudepluginhub faysilalshareef/dotnet-ai-kit --plugin dotnet-ai-kitThis skill uses the workspace's default tool permissions.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Executes pre-written implementation plans: critically reviews, follows bite-sized steps exactly, runs verifications, tracks progress with checkpoints, uses git worktrees, stops on blockers.
Include() and composition, not inheritance hierarchiesCreateOrderRequest, UpdateCustomerCommandIValidateOptions<T>required keyword or ArgumentNullException.ThrowIfNull sufficesusing FluentValidation;
namespace Ordering.Application.Validators;
public sealed class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderRequestValidator()
{
RuleFor(x => x.CustomerId)
.NotEmpty()
.WithMessage("Customer ID is required.");
RuleFor(x => x.OrderDate)
.LessThanOrEqualTo(DateOnly.FromDateTime(DateTime.UtcNow))
.WithMessage("Order date cannot be in the future.");
RuleFor(x => x.LineItems)
.NotEmpty()
.WithMessage("Order must contain at least one line item.");
RuleForEach(x => x.LineItems)
.SetValidator(new LineItemValidator());
}
}
public sealed class LineItemValidator : AbstractValidator<LineItemDto>
{
public LineItemValidator()
{
RuleFor(x => x.ProductId).NotEmpty();
RuleFor(x => x.Quantity).GreaterThan(0);
RuleFor(x => x.UnitPrice).GreaterThan(0m);
}
}
Register all validators from an assembly in one call:
using FluentValidation;
// In Program.cs or a DI module
builder.Services.AddValidatorsFromAssemblyContaining<CreateOrderRequestValidator>();
This scans the assembly and registers every IValidator<T> as Scoped by default. Override with:
builder.Services.AddValidatorsFromAssemblyContaining<CreateOrderRequestValidator>(
lifetime: ServiceLifetime.Transient);
Inject IValidator<T> and call ValidateAsync explicitly:
using FluentValidation;
namespace Ordering.Application.Handlers;
public sealed class CreateOrderHandler(
IValidator<CreateOrderRequest> validator,
IOrderRepository repository)
{
public async Task<Guid> HandleAsync(
CreateOrderRequest request,
CancellationToken ct = default)
{
var result = await validator.ValidateAsync(request, ct);
if (!result.IsValid)
{
throw new ValidationException(result.Errors);
}
var order = Order.Create(request.CustomerId, request.OrderDate, request.LineItems);
await repository.AddAsync(order, ct);
return order.Id;
}
}
Use an endpoint filter to validate before the handler runs:
using FluentValidation;
namespace Ordering.Api.Filters;
public sealed class ValidationFilter<T> : IEndpointFilter where T : class
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var validator = context.HttpContext.RequestServices.GetService<IValidator<T>>();
if (validator is null)
{
return await next(context);
}
var model = context.Arguments.OfType<T>().FirstOrDefault();
if (model is null)
{
return await next(context);
}
var result = await validator.ValidateAsync(model);
if (!result.IsValid)
{
return Results.ValidationProblem(
result.ToDictionary(),
title: "Validation Failed",
type: "https://tools.ietf.org/html/rfc9110#section-15.5.1");
}
return await next(context);
}
}
Register the filter on an endpoint or group:
app.MapPost("/api/orders", CreateOrder)
.AddEndpointFilter<ValidationFilter<CreateOrderRequest>>();
// Or apply to an entire route group
app.MapGroup("/api/orders")
.AddEndpointFilter<ValidationFilter<CreateOrderRequest>>();
Use an action filter that resolves IValidator<> for each action argument:
using FluentValidation;
using FluentValidation.AspNetCore;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace Ordering.Api.Filters;
public sealed class FluentValidationActionFilter(IServiceProvider serviceProvider)
: IAsyncActionFilter
{
public async Task OnActionExecutionAsync(
ActionExecutingContext context, ActionExecutionDelegate next)
{
foreach (var (_, value) in context.ActionArguments)
{
if (value is null) continue;
var validatorType = typeof(IValidator<>).MakeGenericType(value.GetType());
if (serviceProvider.GetService(validatorType) is not IValidator validator)
continue;
var result = await validator.ValidateAsync(new ValidationContext<object>(value));
if (!result.IsValid)
result.AddToModelState(context.ModelState);
}
if (!context.ModelState.IsValid)
{
context.Result = new BadRequestObjectResult(
new ValidationProblemDetails(context.ModelState));
return;
}
await next();
}
}
Register globally in Program.cs:
builder.Services.AddControllers(o => o.Filters.Add<FluentValidationActionFilter>());
Use Must() for synchronous custom rules and cross-field validation:
public sealed class DateRangeRequestValidator : AbstractValidator<DateRangeRequest>
{
public DateRangeRequestValidator()
{
RuleFor(x => x.StartDate)
.NotEmpty();
RuleFor(x => x.EndDate)
.NotEmpty()
.Must((request, endDate) => endDate > request.StartDate)
.WithMessage("End date must be after start date.");
RuleFor(x => x.Currency)
.Must(BeAValidCurrencyCode)
.WithMessage("Currency must be a valid ISO 4217 code.");
}
private static bool BeAValidCurrencyCode(string currency) =>
currency.Length == 3 && currency.All(char.IsUpper);
}
Use MustAsync() when validation requires I/O such as database uniqueness checks:
public sealed class CreateCustomerRequestValidator : AbstractValidator<CreateCustomerRequest>
{
public CreateCustomerRequestValidator(ICustomerRepository repository)
{
RuleFor(x => x.Email)
.NotEmpty().EmailAddress()
.MustAsync(async (email, ct) => !await repository.ExistsByEmailAsync(email, ct))
.WithMessage("A customer with this email already exists.");
RuleFor(x => x.TaxId)
.NotEmpty()
.MustAsync(async (taxId, ct) => !await repository.ExistsByTaxIdAsync(taxId, ct))
.WithMessage("A customer with this Tax ID already exists.");
}
}
Important: async validators require ValidateAsync() -- calling Validate() synchronously will throw.
Extract shared validation logic with Include():
public sealed class PaginationValidator : AbstractValidator<IPaginatedRequest>
{
public PaginationValidator()
{
RuleFor(x => x.Page).GreaterThanOrEqualTo(1);
RuleFor(x => x.PageSize).InclusiveBetween(1, 100);
}
}
public sealed class AuditFieldsValidator : AbstractValidator<IAuditableRequest>
{
public AuditFieldsValidator()
{
RuleFor(x => x.CorrelationId).NotEmpty();
RuleFor(x => x.RequestedBy).NotEmpty().MaximumLength(256);
}
}
public sealed class SearchOrdersRequestValidator : AbstractValidator<SearchOrdersRequest>
{
public SearchOrdersRequestValidator()
{
Include(new PaginationValidator());
Include(new AuditFieldsValidator());
RuleFor(x => x.Status)
.IsInEnum()
.When(x => x.Status.HasValue);
}
}
Map FluentValidation errors to RFC 9457 ProblemDetails via IExceptionHandler:
using FluentValidation;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
namespace Ordering.Api.ExceptionHandlers;
public sealed class ValidationExceptionHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext, Exception exception, CancellationToken ct)
{
if (exception is not ValidationException validationException)
return false;
var errors = validationException.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
var problemDetails = new ValidationProblemDetails(errors)
{
Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1",
Title = "Validation Failed",
Status = StatusCodes.Status400BadRequest,
Instance = httpContext.Request.Path
};
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(problemDetails, ct);
return true;
}
}
Register in Program.cs:
builder.Services.AddExceptionHandler<ValidationExceptionHandler>();
builder.Services.AddProblemDetails();
app.UseExceptionHandler();
| Scenario | Approach | Why |
|---|---|---|
| Simple DTO field constraints | RuleFor with built-in validators | Readable, testable, no custom logic needed |
| Cross-field validation | Must() with model access | Access to sibling properties via (model, field) overload |
| Uniqueness / DB lookup | MustAsync() with injected repo | Requires async I/O — keeps validator DI-friendly |
| Shared pagination / audit rules | Include() with interface validators | Avoids duplication across multiple request validators |
| Minimal API validation | ValidationFilter<T> endpoint filter | Runs before handler, returns ValidationProblem automatically |
| MVC controller validation | FluentValidationActionFilter | Integrates with ModelState and ValidationProblemDetails |
| Complex nested objects | SetValidator() on child + RuleForEach | Composes child validators, validates each collection element |
| Domain invariants | Do NOT use FluentValidation | Enforce inside entity constructor / methods |
| Startup config validation | IValidateOptions<T> with FluentValidation | Fail fast at app start if configuration is invalid |
| Anti-Pattern | Correct Approach |
|---|---|
| Validating domain entities with FluentValidation | Domain entities enforce their own invariants in constructors and methods |
Calling Validate() synchronously when async rules exist | Always use ValidateAsync() — sync call throws on async rules |
| One mega-validator for all request types | One validator per request model, compose with Include() |
| Throwing generic exceptions on validation failure | Throw ValidationException(result.Errors) and handle with IExceptionHandler |
| Duplicating rules across validators | Extract shared rules into reusable validators and use Include() |
| Validating inside the controller action body | Use filters or middleware to validate before the action executes |
Ignoring CancellationToken in async validators | Pass CancellationToken through MustAsync to support request cancellation |
| Hard-coding error messages without context | Use WithMessage() with placeholders: {PropertyName}, {PropertyValue} |
| Registering validators as Singleton when they have scoped dependencies | Use default Scoped lifetime or Transient if injecting scoped services |
AbstractValidator<T> implementations in Application/Validators/Program.cs for AddValidatorsFromAssembly registration callsIExceptionHandler implementations that handle ValidationException{RequestType}Validator