489 lines
11 KiB
Markdown
489 lines
11 KiB
Markdown
# FluentValidation Setup
|
|
|
|
How to install, configure, and use FluentValidation with Svrnty.CQRS.
|
|
|
|
## Installation
|
|
|
|
### Install NuGet Package
|
|
|
|
```bash
|
|
dotnet add package FluentValidation
|
|
dotnet add package FluentValidation.DependencyInjectionExtensions
|
|
```
|
|
|
|
### Package Reference
|
|
|
|
```xml
|
|
<ItemGroup>
|
|
<PackageReference Include="FluentValidation" Version="11.11.0" />
|
|
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
|
|
</ItemGroup>
|
|
```
|
|
|
|
## Creating Validators
|
|
|
|
Validators inherit from `AbstractValidator<T>` and define rules in the constructor.
|
|
|
|
### Basic Validator
|
|
|
|
```csharp
|
|
using FluentValidation;
|
|
|
|
public class CreateUserCommand
|
|
{
|
|
public string Name { get; init; } = string.Empty;
|
|
public string Email { get; init; } = string.Empty;
|
|
public int Age { get; init; }
|
|
}
|
|
|
|
public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
|
|
{
|
|
public CreateUserCommandValidator()
|
|
{
|
|
RuleFor(x => x.Name)
|
|
.NotEmpty()
|
|
.WithMessage("Name is required")
|
|
.MaximumLength(100)
|
|
.WithMessage("Name must not exceed 100 characters");
|
|
|
|
RuleFor(x => x.Email)
|
|
.NotEmpty()
|
|
.EmailAddress()
|
|
.WithMessage("Valid email address is required");
|
|
|
|
RuleFor(x => x.Age)
|
|
.GreaterThanOrEqualTo(18)
|
|
.WithMessage("User must be at least 18 years old")
|
|
.LessThanOrEqualTo(150)
|
|
.WithMessage("Age must be realistic");
|
|
}
|
|
}
|
|
```
|
|
|
|
### Validator with Dependencies
|
|
|
|
```csharp
|
|
public class CreateProductCommandValidator : AbstractValidator<CreateProductCommand>
|
|
{
|
|
private readonly IProductRepository _productRepository;
|
|
|
|
public CreateProductCommandValidator(IProductRepository productRepository)
|
|
{
|
|
_productRepository = productRepository;
|
|
|
|
RuleFor(x => x.Name)
|
|
.NotEmpty()
|
|
.MustAsync(BeUniqueName)
|
|
.WithMessage("Product name already exists");
|
|
|
|
RuleFor(x => x.Price)
|
|
.GreaterThan(0)
|
|
.WithMessage("Price must be greater than zero");
|
|
|
|
RuleFor(x => x.CategoryId)
|
|
.NotEmpty()
|
|
.WithMessage("Category is required");
|
|
}
|
|
|
|
private async Task<bool> BeUniqueName(string name, CancellationToken cancellationToken)
|
|
{
|
|
var existing = await _productRepository.GetByNameAsync(name, cancellationToken);
|
|
return existing == null;
|
|
}
|
|
}
|
|
```
|
|
|
|
## Registering Validators
|
|
|
|
### Individual Registration
|
|
|
|
```csharp
|
|
builder.Services.AddTransient<IValidator<CreateUserCommand>, CreateUserCommandValidator>();
|
|
```
|
|
|
|
### Automatic Registration
|
|
|
|
Register all validators in an assembly:
|
|
|
|
```csharp
|
|
using FluentValidation;
|
|
|
|
builder.Services.AddValidatorsFromAssemblyContaining<CreateUserCommandValidator>();
|
|
```
|
|
|
|
### Registration with Command
|
|
|
|
```csharp
|
|
// Register command
|
|
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
|
|
|
|
// Register validator
|
|
builder.Services.AddTransient<IValidator<CreateUserCommand>, CreateUserCommandValidator>();
|
|
```
|
|
|
|
### Bulk Registration Extension
|
|
|
|
```csharp
|
|
public static class ValidationRegistration
|
|
{
|
|
public static IServiceCollection AddCommandValidators(this IServiceCollection services)
|
|
{
|
|
// Register all validators
|
|
services.AddValidatorsFromAssemblyContaining<CreateUserCommandValidator>();
|
|
|
|
return services;
|
|
}
|
|
}
|
|
|
|
// Usage
|
|
builder.Services.AddCommandValidators();
|
|
```
|
|
|
|
## Common Validation Rules
|
|
|
|
### Required Fields
|
|
|
|
```csharp
|
|
// Not null or empty
|
|
RuleFor(x => x.Name)
|
|
.NotEmpty();
|
|
|
|
// Not null
|
|
RuleFor(x => x.UserId)
|
|
.NotNull();
|
|
```
|
|
|
|
### String Validation
|
|
|
|
```csharp
|
|
// Length
|
|
RuleFor(x => x.Username)
|
|
.MinimumLength(3)
|
|
.MaximumLength(20);
|
|
|
|
// Exact length
|
|
RuleFor(x => x.PostalCode)
|
|
.Length(5);
|
|
|
|
// Email
|
|
RuleFor(x => x.Email)
|
|
.EmailAddress();
|
|
|
|
// Regular expression
|
|
RuleFor(x => x.PhoneNumber)
|
|
.Matches(@"^\+?[1-9]\d{1,14}$")
|
|
.WithMessage("Invalid phone number format");
|
|
```
|
|
|
|
### Numeric Validation
|
|
|
|
```csharp
|
|
// Greater than
|
|
RuleFor(x => x.Age)
|
|
.GreaterThan(0);
|
|
|
|
// Greater than or equal
|
|
RuleFor(x => x.Quantity)
|
|
.GreaterThanOrEqualTo(1);
|
|
|
|
// Less than
|
|
RuleFor(x => x.Discount)
|
|
.LessThan(100);
|
|
|
|
// Range
|
|
RuleFor(x => x.Rating)
|
|
.InclusiveBetween(1, 5);
|
|
```
|
|
|
|
### Collection Validation
|
|
|
|
```csharp
|
|
// Not empty collection
|
|
RuleFor(x => x.Items)
|
|
.NotEmpty()
|
|
.WithMessage("At least one item is required");
|
|
|
|
// Collection count
|
|
RuleFor(x => x.Tags)
|
|
.Must(tags => tags.Count <= 10)
|
|
.WithMessage("Maximum 10 tags allowed");
|
|
|
|
// Validate each item
|
|
RuleForEach(x => x.Items)
|
|
.SetValidator(new OrderItemValidator());
|
|
```
|
|
|
|
### Complex Object Validation
|
|
|
|
```csharp
|
|
// Nested object
|
|
RuleFor(x => x.Address)
|
|
.NotNull()
|
|
.SetValidator(new AddressValidator());
|
|
|
|
// Conditional validation
|
|
RuleFor(x => x.CompanyName)
|
|
.NotEmpty()
|
|
.When(x => x.IsCompany);
|
|
```
|
|
|
|
### Custom Predicates
|
|
|
|
```csharp
|
|
// Must satisfy predicate
|
|
RuleFor(x => x.StartDate)
|
|
.Must(date => date > DateTime.UtcNow)
|
|
.WithMessage("Start date must be in the future");
|
|
|
|
// Must satisfy async predicate
|
|
RuleFor(x => x.Email)
|
|
.MustAsync(async (email, cancellationToken) =>
|
|
{
|
|
var exists = await _userRepository.EmailExistsAsync(email, cancellationToken);
|
|
return !exists;
|
|
})
|
|
.WithMessage("Email is already registered");
|
|
```
|
|
|
|
## Conditional Validation
|
|
|
|
### When
|
|
|
|
```csharp
|
|
RuleFor(x => x.CompanyName)
|
|
.NotEmpty()
|
|
.When(x => x.IsCompany);
|
|
|
|
RuleFor(x => x.TaxId)
|
|
.NotEmpty()
|
|
.When(x => x.IsCompany);
|
|
```
|
|
|
|
### Unless
|
|
|
|
```csharp
|
|
RuleFor(x => x.MiddleName)
|
|
.NotEmpty()
|
|
.Unless(x => x.PreferNoMiddleName);
|
|
```
|
|
|
|
## Cross-Property Validation
|
|
|
|
```csharp
|
|
public class DateRangeValidator : AbstractValidator<SearchQuery>
|
|
{
|
|
public DateRangeValidator()
|
|
{
|
|
RuleFor(x => x.EndDate)
|
|
.GreaterThanOrEqualTo(x => x.StartDate)
|
|
.WithMessage("End date must be after start date");
|
|
|
|
RuleFor(x => x)
|
|
.Must(q => (q.EndDate - q.StartDate).TotalDays <= 90)
|
|
.WithMessage("Date range must not exceed 90 days");
|
|
}
|
|
}
|
|
```
|
|
|
|
## Async Validation
|
|
|
|
### Database Lookup
|
|
|
|
```csharp
|
|
public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
|
|
{
|
|
private readonly IUserRepository _userRepository;
|
|
|
|
public CreateUserCommandValidator(IUserRepository userRepository)
|
|
{
|
|
_userRepository = userRepository;
|
|
|
|
RuleFor(x => x.Email)
|
|
.MustAsync(BeUniqueEmail)
|
|
.WithMessage("Email is already registered");
|
|
}
|
|
|
|
private async Task<bool> BeUniqueEmail(string email, CancellationToken cancellationToken)
|
|
{
|
|
var user = await _userRepository.GetByEmailAsync(email, cancellationToken);
|
|
return user == null;
|
|
}
|
|
}
|
|
```
|
|
|
|
### External API Call
|
|
|
|
```csharp
|
|
public class ValidateAddressCommandValidator : AbstractValidator<ValidateAddressCommand>
|
|
{
|
|
private readonly IAddressValidationService _validationService;
|
|
|
|
public ValidateAddressCommandValidator(IAddressValidationService validationService)
|
|
{
|
|
_validationService = validationService;
|
|
|
|
RuleFor(x => x.ZipCode)
|
|
.MustAsync(BeValidZipCode)
|
|
.WithMessage("Invalid zip code");
|
|
}
|
|
|
|
private async Task<bool> BeValidZipCode(string zipCode, CancellationToken cancellationToken)
|
|
{
|
|
return await _validationService.IsValidZipCodeAsync(zipCode, cancellationToken);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Validation Messages
|
|
|
|
### Custom Messages
|
|
|
|
```csharp
|
|
RuleFor(x => x.Name)
|
|
.NotEmpty()
|
|
.WithMessage("Name is required");
|
|
|
|
RuleFor(x => x.Age)
|
|
.GreaterThanOrEqualTo(18)
|
|
.WithMessage("You must be at least 18 years old to register");
|
|
```
|
|
|
|
### Placeholder Messages
|
|
|
|
```csharp
|
|
RuleFor(x => x.Name)
|
|
.MaximumLength(100)
|
|
.WithMessage("Name must not exceed {MaxLength} characters. You entered {TotalLength} characters.");
|
|
|
|
RuleFor(x => x.Quantity)
|
|
.InclusiveBetween(1, 100)
|
|
.WithMessage("Quantity must be between {From} and {To}. You entered {PropertyValue}.");
|
|
```
|
|
|
|
### Property Name Overrides
|
|
|
|
```csharp
|
|
RuleFor(x => x.EmailAddress)
|
|
.NotEmpty()
|
|
.WithName("Email");
|
|
// Error: "Email is required" (instead of "Email Address is required")
|
|
```
|
|
|
|
## Rule Sets
|
|
|
|
```csharp
|
|
public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
|
|
{
|
|
public CreateUserCommandValidator()
|
|
{
|
|
// Default rules (always run)
|
|
RuleFor(x => x.Name)
|
|
.NotEmpty();
|
|
|
|
// Rule set for creating
|
|
RuleSet("Create", () =>
|
|
{
|
|
RuleFor(x => x.Email)
|
|
.MustAsync(BeUniqueEmail);
|
|
});
|
|
|
|
// Rule set for updating
|
|
RuleSet("Update", () =>
|
|
{
|
|
RuleFor(x => x.UserId)
|
|
.GreaterThan(0);
|
|
});
|
|
}
|
|
}
|
|
|
|
// Validate with specific rule set
|
|
var result = await validator.ValidateAsync(command, options =>
|
|
{
|
|
options.IncludeRuleSets("Create");
|
|
});
|
|
```
|
|
|
|
## Testing Validators
|
|
|
|
```csharp
|
|
using FluentValidation.TestHelper;
|
|
using Xunit;
|
|
|
|
public class CreateUserCommandValidatorTests
|
|
{
|
|
private readonly CreateUserCommandValidator _validator;
|
|
|
|
public CreateUserCommandValidatorTests()
|
|
{
|
|
_validator = new CreateUserCommandValidator();
|
|
}
|
|
|
|
[Fact]
|
|
public void Should_Require_Name()
|
|
{
|
|
var command = new CreateUserCommand { Name = "" };
|
|
var result = _validator.TestValidate(command);
|
|
result.ShouldHaveValidationErrorFor(x => x.Name);
|
|
}
|
|
|
|
[Fact]
|
|
public void Should_Require_Valid_Email()
|
|
{
|
|
var command = new CreateUserCommand { Email = "invalid" };
|
|
var result = _validator.TestValidate(command);
|
|
result.ShouldHaveValidationErrorFor(x => x.Email);
|
|
}
|
|
|
|
[Fact]
|
|
public void Should_Reject_Under_Age()
|
|
{
|
|
var command = new CreateUserCommand { Age = 16 };
|
|
var result = _validator.TestValidate(command);
|
|
result.ShouldHaveValidationErrorFor(x => x.Age);
|
|
}
|
|
|
|
[Fact]
|
|
public void Should_Accept_Valid_Command()
|
|
{
|
|
var command = new CreateUserCommand
|
|
{
|
|
Name = "John Doe",
|
|
Email = "john@example.com",
|
|
Age = 25
|
|
};
|
|
|
|
var result = _validator.TestValidate(command);
|
|
result.ShouldNotHaveAnyValidationErrors();
|
|
}
|
|
}
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
### ✅ DO
|
|
|
|
- Create one validator per command/query
|
|
- Use descriptive error messages
|
|
- Keep validators focused and single-purpose
|
|
- Use async validation for I/O operations
|
|
- Test validators independently
|
|
- Register validators with DI
|
|
- Use rule sets for complex scenarios
|
|
|
|
### ❌ DON'T
|
|
|
|
- Don't perform business logic in validators
|
|
- Don't modify state in validators
|
|
- Don't throw exceptions (return validation results)
|
|
- Don't validate domain entities (validate DTOs/commands)
|
|
- Don't skip async validation when needed
|
|
- Don't create validators without tests
|
|
|
|
## See Also
|
|
|
|
- [Validation Overview](README.md)
|
|
- [HTTP Validation](http-validation.md)
|
|
- [gRPC Validation](grpc-validation.md)
|
|
- [Custom Validation](custom-validation.md)
|
|
- [FluentValidation Documentation](https://docs.fluentvalidation.net/)
|