Core Solution
Architecture Decisions and Rationale
Production-grade validation requires a layered approach:
- Separation of Concerns: Validation rules must reside in dedicated validator classes, not DTOs. This preserves model purity and allows rules to evolve independently.
- Dependency Injection: Validators should be registered in the DI container to enable mocking in tests and injection of services (e.g.,
DbContext) for async validation.
- Unified Error Handling: Validation errors must be transformed into consistent
ProblemDetails responses to ensure API contract stability.
- Pipeline Integration: Validation should execute early in the request pipeline, failing fast before business logic processing begins.
Step-by-Step Implementation
1. Define the DTO
Keep the model clean. Use C# 11+ required properties for structural integrity, but defer business rules to the validator.
public record CreateUserRequest(
string Email,
string Password,
int Age,
List<string> Roles
);
2. Create the Validator
Implement AbstractValidator<T> from the FluentValidation library. This class encapsulates all rules.
using FluentValidation;
using Microsoft.EntityFrameworkCore;
public class CreateUserRequestValidator : AbstractValidator<CreateUserRequest>
{
private readonly ApplicationDbContext _context;
// Inject services for async validation
public CreateUserRequestValidator(ApplicationDbContext context)
{
_context = context;
RuleFor(x => x.Email)
.NotEmpty().WithMessage("Email is required.")
.EmailAddress().WithMessage("Invalid email format.")
.MustAsync(BeUniqueEmail).WithMessage("Email already exists.");
RuleFor(x => x.Password)
.MinimumLength(8).WithMessage("Password must be at least 8 characters.")
.Matches("[A-Z]").WithMessage("Password must contain an uppercase letter.")
.Matches("[0-9]").WithMessage("Password must contain a digit.");
RuleFor(x => x.Age)
.InclusiveBetween(18, 120).WithMessage("Age must be between 18 and 120.");
RuleForEach(x => x.Roles)
.Must(BeValidRole).WithMessage("Invalid role specified.");
}
private async Task<bool> BeUniqueEmail(string email, CancellationToken ct)
{
return !await _context.Users.AnyAsync(u => u.Email == email, ct);
}
private bool BeValidRole(string role)
{
return new[] { "Admin", "User", "Editor" }.Contains(role);
}
}
3. Register Validators in DI
Configure the application to automatically discover and register validators.
// Program.cs
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddValidatorsFromAssemblyContaining<CreateUserRequestValidator>();
// Optional: Configure global error response format
builder.Services.Configure<ApiBehaviorOptions>(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
var errors = context.ModelState
.Where(e => e.Value.Errors.Count > 0)
.SelectMany(e => e.Value.Errors.Select(c => c.ErrorMessage))
.ToList();
var problem = new ValidationProblemDetails(context.ModelState)
{
Status = StatusCodes.Status400BadRequest,
Title = "Validation failed",
Detail = "One or more validation errors occurred."
};
return new BadRequestObjectResult(problem);
};
});
4. Controller Usage
The controller remains clean. Validation is triggered automatically by the framework.
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly IUserService _userService;
public UsersController(IUserService userService)
{
_userService = userService;
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateUserRequest request)
{
// ModelState.IsValid is automatically populated by FluentValidation
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var user = await _userService.CreateAsync(request);
return CreatedAtAction(nameof(Get), new { id = user.Id }, user);
}
}
Advanced Patterns
Context-Dependent Validation
Use validation rulesets for scenarios where rules vary by context (e.g., registration vs. profile update).
RuleFor(x => x.Password)
.NotEmpty()
.OverridePropertyNameForChildren()
.WithMessage("Password is required.")
.When(x => x.IsNewUser, ApplyConditionTo.CurrentValidator);
Custom Validators
For reusable logic, create custom extensions.
public static class CustomValidators
{
public static IRuleBuilderOptions<T, string> MustBeFutureDate<T>(this IRuleBuilder<T, string> ruleBuilder)
{
return ruleBuilder.Must(date => DateTime.TryParse(date, out var d) && d > DateTime.UtcNow)
.WithMessage("Date must be in the future.");
}
}
Pitfall Guide
1. Mixing Validation with Business Logic
Mistake: Implementing validation inside the service layer or controller.
Impact: Duplicates logic, complicates testing, and delays failure detection.
Fix: Enforce validation at the API boundary. Business logic should assume valid input or use domain validation for invariants, but API validation must occur first.
Mistake: Using MustAsync for database checks without caching or optimization.
Impact: N+1 query problems and latency spikes.
Fix: Batch async validations where possible. Use caching for static lookups. Ensure database indexes support validation queries.
3. Over-Reliance on ModelState.IsValid in Controllers
Mistake: Manually checking ModelState and returning errors in every action.
Impact: Boilerplate code and inconsistent error responses.
Fix: Configure InvalidModelStateResponseFactory globally or use a validation filter to handle errors uniformly.
4. Mass Assignment Vulnerabilities
Mistake: Binding directly to domain entities or using DTOs with sensitive properties.
Impact: Attackers can modify unauthorized fields (e.g., IsAdmin, RoleId).
Fix: Always use dedicated DTOs for input. Exclude sensitive properties from binding. Validate roles and permissions explicitly.
5. Null Reference and NRT Confusion
Mistake: Relying on [Required] for nullable reference types in C# 11+.
Impact: Redundant validation; NRTs provide compile-time safety.
Fix: Use required keyword for structural requirements. Reserve [Required] or FluentValidation for business constraints on non-nullable types.
6. Inconsistent Error Messages
Mistake: Returning internal exception details or vague messages.
Impact: Poor developer experience and security leakage.
Fix: Standardize error codes and messages. Use ProblemDetails with machine-readable error codes. Never expose stack traces.
7. Circular Dependencies in Validators
Mistake: Injecting services that depend on the validator or model.
Impact: Runtime exceptions during DI resolution.
Fix: Keep validators stateless where possible. Inject only necessary services. Use factory patterns if complex dependencies are required.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Simple CRUD API | DataAnnotations | Rapid development; low complexity. | Low |
| Complex Business Rules | FluentValidation | Separation of concerns; testability; composition. | Medium |
| High-Performance Microservice | Source Generators | Minimal runtime overhead; AOT compatibility. | High |
| Security-Critical Endpoint | FluentValidation + Policy | Defense in depth; explicit rule enforcement. | Medium |
| Legacy Migration | Hybrid (Fluent + Attributes) | Incremental adoption; backward compatibility. | Medium |
Configuration Template
Program.cs
using FluentValidation;
using YourApp.Validators;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers()
.AddFluentValidation(fv =>
{
fv.RegisterValidatorsFromAssemblyContaining<CreateUserRequestValidator>();
fv.RunDefaultMvcValidationAfterFluentValidationExecutes = false;
});
builder.Services.Configure<ApiBehaviorOptions>(options =>
{
options.InvalidModelStateResponseFactory = actionContext =>
{
var errors = actionContext.ModelState
.Where(e => e.Value.Errors.Count > 0)
.SelectMany(e => e.Value.Errors.Select(c => c.ErrorMessage))
.ToList();
return new BadRequestObjectResult(new ValidationProblemDetails(actionContext.ModelState)
{
Status = StatusCodes.Status400BadRequest,
Title = "Validation Error",
Extensions = { { "errors", errors } }
});
};
});
var app = builder.Build();
app.MapControllers();
app.Run();
Quick Start Guide
- Install Package:
dotnet add package FluentValidation.AspNetCore
- Create DTO and Validator:
Define your request model and a corresponding validator class inheriting from
AbstractValidator<T>.
- Register Services:
Add
builder.Services.AddFluentValidationAutoValidation(); and builder.Services.AddValidatorsFromAssemblyContaining<YourValidator>(); to Program.cs.
- Test Endpoint:
Send a request with invalid data. Verify that the API returns a
400 Bad Request with detailed validation errors in the ProblemDetails format.
- Write Unit Test:
Use
validator.TestValidate(request).ShouldHaveValidationErrorFor(x => x.Property) to assert validation behavior.
Codcompass Editorial Note: Validation is not merely a defensive mechanism; it is a contract enforcement layer. Treat it with the same architectural rigor as your business logic. Invest in separated validators, comprehensive testing, and consistent error handling to build resilient, maintainable ASP.NET Core applications.