595 lines
16 KiB
Markdown
595 lines
16 KiB
Markdown
# Custom Validation
|
|
|
|
Advanced validation scenarios for complex business rules.
|
|
|
|
## Overview
|
|
|
|
Custom validation extends beyond simple field validation to handle:
|
|
|
|
- ✅ **Async validation** - Database lookups, external API calls
|
|
- ✅ **Cross-property validation** - Validate relationships between fields
|
|
- ✅ **Conditional validation** - Rules that apply based on other fields
|
|
- ✅ **Custom validators** - Reusable validation logic
|
|
- ✅ **Database validation** - Check uniqueness, existence, state
|
|
- ✅ **Business rule validation** - Complex domain rules
|
|
|
|
## Custom Validators
|
|
|
|
### Reusable Custom Validator
|
|
|
|
```csharp
|
|
public class PasswordValidator : AbstractValidator<string>
|
|
{
|
|
public PasswordValidator()
|
|
{
|
|
RuleFor(password => password)
|
|
.NotEmpty()
|
|
.WithMessage("Password is required")
|
|
.MinimumLength(8)
|
|
.WithMessage("Password must be at least 8 characters")
|
|
.Matches("[A-Z]")
|
|
.WithMessage("Password must contain at least one uppercase letter")
|
|
.Matches("[a-z]")
|
|
.WithMessage("Password must contain at least one lowercase letter")
|
|
.Matches("[0-9]")
|
|
.WithMessage("Password must contain at least one digit")
|
|
.Matches("[^a-zA-Z0-9]")
|
|
.WithMessage("Password must contain at least one special character");
|
|
}
|
|
}
|
|
|
|
// Usage
|
|
public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
|
|
{
|
|
public CreateUserCommandValidator()
|
|
{
|
|
RuleFor(x => x.Password)
|
|
.SetValidator(new PasswordValidator());
|
|
}
|
|
}
|
|
```
|
|
|
|
### Nested Object Validator
|
|
|
|
```csharp
|
|
public class AddressValidator : AbstractValidator<Address>
|
|
{
|
|
public AddressValidator()
|
|
{
|
|
RuleFor(x => x.Street)
|
|
.NotEmpty()
|
|
.MaximumLength(100);
|
|
|
|
RuleFor(x => x.City)
|
|
.NotEmpty()
|
|
.MaximumLength(50);
|
|
|
|
RuleFor(x => x.PostalCode)
|
|
.NotEmpty()
|
|
.Matches(@"^\d{5}(-\d{4})?$")
|
|
.WithMessage("Invalid postal code format");
|
|
|
|
RuleFor(x => x.Country)
|
|
.NotEmpty()
|
|
.Must(BeValidCountryCode)
|
|
.WithMessage("Invalid country code");
|
|
}
|
|
|
|
private bool BeValidCountryCode(string code)
|
|
{
|
|
var validCodes = new[] { "US", "CA", "GB", "FR", "DE" };
|
|
return validCodes.Contains(code);
|
|
}
|
|
}
|
|
|
|
// Usage
|
|
public class CreateCompanyCommandValidator : AbstractValidator<CreateCompanyCommand>
|
|
{
|
|
public CreateCompanyCommandValidator()
|
|
{
|
|
RuleFor(x => x.Name)
|
|
.NotEmpty();
|
|
|
|
RuleFor(x => x.Address)
|
|
.NotNull()
|
|
.SetValidator(new AddressValidator());
|
|
}
|
|
}
|
|
```
|
|
|
|
## Async Validation
|
|
|
|
### Database Uniqueness Check
|
|
|
|
```csharp
|
|
public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
|
|
{
|
|
private readonly IUserRepository _userRepository;
|
|
|
|
public CreateUserCommandValidator(IUserRepository userRepository)
|
|
{
|
|
_userRepository = userRepository;
|
|
|
|
RuleFor(x => x.Email)
|
|
.NotEmpty()
|
|
.EmailAddress()
|
|
.MustAsync(BeUniqueEmail)
|
|
.WithMessage("Email address is already registered");
|
|
|
|
RuleFor(x => x.Username)
|
|
.NotEmpty()
|
|
.MustAsync(BeUniqueUsername)
|
|
.WithMessage("Username is already taken");
|
|
}
|
|
|
|
private async Task<bool> BeUniqueEmail(string email, CancellationToken cancellationToken)
|
|
{
|
|
var existingUser = await _userRepository.GetByEmailAsync(email, cancellationToken);
|
|
return existingUser == null;
|
|
}
|
|
|
|
private async Task<bool> BeUniqueUsername(string username, CancellationToken cancellationToken)
|
|
{
|
|
var existingUser = await _userRepository.GetByUsernameAsync(username, cancellationToken);
|
|
return existingUser == null;
|
|
}
|
|
}
|
|
```
|
|
|
|
### Entity Existence Check
|
|
|
|
```csharp
|
|
public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
|
|
{
|
|
private readonly ICustomerRepository _customerRepository;
|
|
private readonly IProductRepository _productRepository;
|
|
|
|
public CreateOrderCommandValidator(
|
|
ICustomerRepository customerRepository,
|
|
IProductRepository productRepository)
|
|
{
|
|
_customerRepository = customerRepository;
|
|
_productRepository = productRepository;
|
|
|
|
RuleFor(x => x.CustomerId)
|
|
.GreaterThan(0)
|
|
.MustAsync(CustomerExists)
|
|
.WithMessage("Customer not found");
|
|
|
|
RuleForEach(x => x.Items)
|
|
.MustAsync(ProductExists)
|
|
.WithMessage("Product not found");
|
|
}
|
|
|
|
private async Task<bool> CustomerExists(int customerId, CancellationToken cancellationToken)
|
|
{
|
|
var customer = await _customerRepository.GetByIdAsync(customerId, cancellationToken);
|
|
return customer != null;
|
|
}
|
|
|
|
private async Task<bool> ProductExists(OrderItem item, CancellationToken cancellationToken)
|
|
{
|
|
var product = await _productRepository.GetByIdAsync(item.ProductId, cancellationToken);
|
|
return product != null;
|
|
}
|
|
}
|
|
```
|
|
|
|
### External API Validation
|
|
|
|
```csharp
|
|
public class ValidateAddressCommandValidator : AbstractValidator<ValidateAddressCommand>
|
|
{
|
|
private readonly IAddressValidationService _validationService;
|
|
|
|
public ValidateAddressCommandValidator(IAddressValidationService validationService)
|
|
{
|
|
_validationService = validationService;
|
|
|
|
RuleFor(x => x.ZipCode)
|
|
.NotEmpty()
|
|
.MustAsync(BeValidZipCode)
|
|
.WithMessage("Invalid zip code");
|
|
|
|
RuleFor(x => x)
|
|
.MustAsync(BeValidAddress)
|
|
.WithMessage("Address could not be validated");
|
|
}
|
|
|
|
private async Task<bool> BeValidZipCode(string zipCode, CancellationToken cancellationToken)
|
|
{
|
|
return await _validationService.IsValidZipCodeAsync(zipCode, cancellationToken);
|
|
}
|
|
|
|
private async Task<bool> BeValidAddress(
|
|
ValidateAddressCommand command,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var result = await _validationService.ValidateAddressAsync(
|
|
command.Street,
|
|
command.City,
|
|
command.State,
|
|
command.ZipCode,
|
|
cancellationToken);
|
|
|
|
return result.IsValid;
|
|
}
|
|
}
|
|
```
|
|
|
|
## Cross-Property Validation
|
|
|
|
### Date Range Validation
|
|
|
|
```csharp
|
|
public class SearchOrdersQueryValidator : AbstractValidator<SearchOrdersQuery>
|
|
{
|
|
public SearchOrdersQueryValidator()
|
|
{
|
|
RuleFor(x => x.StartDate)
|
|
.NotEmpty()
|
|
.WithMessage("Start date is required");
|
|
|
|
RuleFor(x => x.EndDate)
|
|
.NotEmpty()
|
|
.WithMessage("End date is required")
|
|
.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");
|
|
}
|
|
}
|
|
```
|
|
|
|
### Price Range Validation
|
|
|
|
```csharp
|
|
public class SearchProductsQueryValidator : AbstractValidator<SearchProductsQuery>
|
|
{
|
|
public SearchProductsQueryValidator()
|
|
{
|
|
When(x => x.MinPrice.HasValue && x.MaxPrice.HasValue, () =>
|
|
{
|
|
RuleFor(x => x.MaxPrice)
|
|
.GreaterThanOrEqualTo(x => x.MinPrice)
|
|
.WithMessage("Maximum price must be greater than minimum price");
|
|
});
|
|
|
|
RuleFor(x => x)
|
|
.Must(q => !q.MaxPrice.HasValue || q.MaxPrice.Value <= 100000)
|
|
.WithMessage("Maximum price cannot exceed 100,000");
|
|
}
|
|
}
|
|
```
|
|
|
|
### Conditional Field Requirements
|
|
|
|
```csharp
|
|
public class CreateShipmentCommandValidator : AbstractValidator<CreateShipmentCommand>
|
|
{
|
|
public CreateShipmentCommandValidator()
|
|
{
|
|
RuleFor(x => x.ShippingMethod)
|
|
.NotEmpty()
|
|
.WithMessage("Shipping method is required");
|
|
|
|
// Require tracking number for non-pickup methods
|
|
When(x => x.ShippingMethod != "Pickup", () =>
|
|
{
|
|
RuleFor(x => x.TrackingNumber)
|
|
.NotEmpty()
|
|
.WithMessage("Tracking number is required for shipped orders");
|
|
});
|
|
|
|
// Require pickup location for pickup method
|
|
When(x => x.ShippingMethod == "Pickup", () =>
|
|
{
|
|
RuleFor(x => x.PickupLocation)
|
|
.NotEmpty()
|
|
.WithMessage("Pickup location is required");
|
|
});
|
|
|
|
// Require signature for high-value shipments
|
|
When(x => x.TotalValue > 1000, () =>
|
|
{
|
|
RuleFor(x => x.RequireSignature)
|
|
.Equal(true)
|
|
.WithMessage("Signature is required for shipments over $1,000");
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
## Conditional Validation
|
|
|
|
### When/Unless
|
|
|
|
```csharp
|
|
public class UpdateUserCommandValidator : AbstractValidator<UpdateUserCommand>
|
|
{
|
|
public UpdateUserCommandValidator()
|
|
{
|
|
RuleFor(x => x.UserId)
|
|
.GreaterThan(0);
|
|
|
|
// Company-specific fields
|
|
When(x => x.IsCompany, () =>
|
|
{
|
|
RuleFor(x => x.CompanyName)
|
|
.NotEmpty()
|
|
.WithMessage("Company name is required for business accounts");
|
|
|
|
RuleFor(x => x.TaxId)
|
|
.NotEmpty()
|
|
.WithMessage("Tax ID is required for business accounts");
|
|
});
|
|
|
|
// Individual-specific fields
|
|
Unless(x => x.IsCompany, () =>
|
|
{
|
|
RuleFor(x => x.FirstName)
|
|
.NotEmpty()
|
|
.WithMessage("First name is required for individual accounts");
|
|
|
|
RuleFor(x => x.LastName)
|
|
.NotEmpty()
|
|
.WithMessage("Last name is required for individual accounts");
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
### Cascading Validation
|
|
|
|
```csharp
|
|
public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
|
|
{
|
|
public CreateOrderCommandValidator()
|
|
{
|
|
RuleFor(x => x.Items)
|
|
.NotEmpty()
|
|
.WithMessage("Order must contain at least one item");
|
|
|
|
// Only validate items if collection is not empty
|
|
RuleForEach(x => x.Items)
|
|
.SetValidator(new OrderItemValidator())
|
|
.When(x => x.Items != null && x.Items.Any());
|
|
}
|
|
}
|
|
|
|
public class OrderItemValidator : AbstractValidator<OrderItem>
|
|
{
|
|
public OrderItemValidator()
|
|
{
|
|
RuleFor(x => x.ProductId)
|
|
.GreaterThan(0);
|
|
|
|
RuleFor(x => x.Quantity)
|
|
.GreaterThan(0)
|
|
.LessThanOrEqualTo(100);
|
|
|
|
RuleFor(x => x.Price)
|
|
.GreaterThan(0);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Collection Validation
|
|
|
|
### Validate Each Item
|
|
|
|
```csharp
|
|
public class BatchCreateUsersCommandValidator : AbstractValidator<BatchCreateUsersCommand>
|
|
{
|
|
public BatchCreateUsersCommandValidator()
|
|
{
|
|
RuleFor(x => x.Users)
|
|
.NotEmpty()
|
|
.WithMessage("At least one user is required");
|
|
|
|
RuleFor(x => x.Users)
|
|
.Must(users => users.Count <= 100)
|
|
.WithMessage("Cannot create more than 100 users at once");
|
|
|
|
RuleForEach(x => x.Users)
|
|
.SetValidator(new CreateUserCommandValidator());
|
|
}
|
|
}
|
|
```
|
|
|
|
### Unique Collection Items
|
|
|
|
```csharp
|
|
public class CreatePlaylistCommandValidator : AbstractValidator<CreatePlaylistCommand>
|
|
{
|
|
public CreatePlaylistCommandValidator()
|
|
{
|
|
RuleFor(x => x.Name)
|
|
.NotEmpty();
|
|
|
|
RuleFor(x => x.SongIds)
|
|
.Must(BeUniqueItems)
|
|
.WithMessage("Duplicate songs are not allowed");
|
|
}
|
|
|
|
private bool BeUniqueItems(List<int> songIds)
|
|
{
|
|
return songIds.Distinct().Count() == songIds.Count;
|
|
}
|
|
}
|
|
```
|
|
|
|
## Complex Business Rules
|
|
|
|
### State Machine Validation
|
|
|
|
```csharp
|
|
public class UpdateOrderStatusCommandValidator : AbstractValidator<UpdateOrderStatusCommand>
|
|
{
|
|
private readonly IOrderRepository _orderRepository;
|
|
|
|
public UpdateOrderStatusCommandValidator(IOrderRepository orderRepository)
|
|
{
|
|
_orderRepository = orderRepository;
|
|
|
|
RuleFor(x => x.OrderId)
|
|
.GreaterThan(0);
|
|
|
|
RuleFor(x => x)
|
|
.MustAsync(BeValidStatusTransition)
|
|
.WithMessage("Invalid status transition");
|
|
}
|
|
|
|
private async Task<bool> BeValidStatusTransition(
|
|
UpdateOrderStatusCommand command,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var order = await _orderRepository.GetByIdAsync(command.OrderId, cancellationToken);
|
|
|
|
if (order == null)
|
|
return false;
|
|
|
|
var validTransitions = new Dictionary<string, string[]>
|
|
{
|
|
["Pending"] = new[] { "Confirmed", "Cancelled" },
|
|
["Confirmed"] = new[] { "Shipped", "Cancelled" },
|
|
["Shipped"] = new[] { "Delivered" },
|
|
["Delivered"] = new[] { },
|
|
["Cancelled"] = new[] { }
|
|
};
|
|
|
|
return validTransitions.ContainsKey(order.Status) &&
|
|
validTransitions[order.Status].Contains(command.NewStatus);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Multi-Tenant Validation
|
|
|
|
```csharp
|
|
public class CreateProductCommandValidator : AbstractValidator<CreateProductCommand>
|
|
{
|
|
private readonly ITenantContext _tenantContext;
|
|
private readonly IProductRepository _productRepository;
|
|
|
|
public CreateProductCommandValidator(
|
|
ITenantContext tenantContext,
|
|
IProductRepository productRepository)
|
|
{
|
|
_tenantContext = tenantContext;
|
|
_productRepository = productRepository;
|
|
|
|
RuleFor(x => x.Name)
|
|
.NotEmpty()
|
|
.MustAsync(BeUniqueWithinTenant)
|
|
.WithMessage("Product name already exists in your organization");
|
|
|
|
RuleFor(x => x.CategoryId)
|
|
.MustAsync(BelongToTenant)
|
|
.WithMessage("Category does not exist in your organization");
|
|
}
|
|
|
|
private async Task<bool> BeUniqueWithinTenant(string name, CancellationToken cancellationToken)
|
|
{
|
|
var tenantId = _tenantContext.TenantId;
|
|
var existing = await _productRepository.GetByNameAsync(tenantId, name, cancellationToken);
|
|
return existing == null;
|
|
}
|
|
|
|
private async Task<bool> BelongToTenant(int categoryId, CancellationToken cancellationToken)
|
|
{
|
|
var tenantId = _tenantContext.TenantId;
|
|
var category = await _categoryRepository.GetByIdAsync(categoryId, cancellationToken);
|
|
return category?.TenantId == tenantId;
|
|
}
|
|
}
|
|
```
|
|
|
|
## Testing Custom Validators
|
|
|
|
### Test Async Validation
|
|
|
|
```csharp
|
|
public class CreateUserCommandValidatorTests
|
|
{
|
|
private readonly Mock<IUserRepository> _mockRepository;
|
|
private readonly CreateUserCommandValidator _validator;
|
|
|
|
public CreateUserCommandValidatorTests()
|
|
{
|
|
_mockRepository = new Mock<IUserRepository>();
|
|
_validator = new CreateUserCommandValidator(_mockRepository.Object);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Should_Fail_When_Email_Already_Exists()
|
|
{
|
|
// Arrange
|
|
var command = new CreateUserCommand { Email = "existing@example.com" };
|
|
|
|
_mockRepository
|
|
.Setup(r => r.GetByEmailAsync("existing@example.com", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new User { Email = "existing@example.com" });
|
|
|
|
// Act
|
|
var result = await _validator.TestValidateAsync(command);
|
|
|
|
// Assert
|
|
result.ShouldHaveValidationErrorFor(x => x.Email)
|
|
.WithErrorMessage("Email address is already registered");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Should_Pass_When_Email_Is_Unique()
|
|
{
|
|
// Arrange
|
|
var command = new CreateUserCommand
|
|
{
|
|
Name = "John Doe",
|
|
Email = "new@example.com"
|
|
};
|
|
|
|
_mockRepository
|
|
.Setup(r => r.GetByEmailAsync("new@example.com", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync((User)null);
|
|
|
|
// Act
|
|
var result = await _validator.TestValidateAsync(command);
|
|
|
|
// Assert
|
|
result.ShouldNotHaveValidationErrorFor(x => x.Email);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
### ✅ DO
|
|
|
|
- Use async validation for I/O operations
|
|
- Cache validation results when possible
|
|
- Keep validators focused and testable
|
|
- Use descriptive error messages
|
|
- Test all validation paths
|
|
- Consider performance impact of async validation
|
|
- Use conditional validation appropriately
|
|
|
|
### ❌ DON'T
|
|
|
|
- Don't perform business logic in validators
|
|
- Don't modify state in validators
|
|
- Don't catch and ignore exceptions
|
|
- Don't make multiple database calls for same data
|
|
- Don't validate in multiple places
|
|
- Don't skip validation for "trusted" input
|
|
|
|
## See Also
|
|
|
|
- [Validation Overview](README.md)
|
|
- [FluentValidation Setup](fluentvalidation-setup.md)
|
|
- [HTTP Validation](http-validation.md)
|
|
- [gRPC Validation](grpc-validation.md)
|
|
- [FluentValidation Documentation](https://docs.fluentvalidation.net/)
|