# 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 { 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 { public CreateUserCommandValidator() { RuleFor(x => x.Password) .SetValidator(new PasswordValidator()); } } ``` ### Nested Object Validator ```csharp public class AddressValidator : AbstractValidator
{ 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 { 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 { 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 BeUniqueEmail(string email, CancellationToken cancellationToken) { var existingUser = await _userRepository.GetByEmailAsync(email, cancellationToken); return existingUser == null; } private async Task BeUniqueUsername(string username, CancellationToken cancellationToken) { var existingUser = await _userRepository.GetByUsernameAsync(username, cancellationToken); return existingUser == null; } } ``` ### Entity Existence Check ```csharp public class CreateOrderCommandValidator : AbstractValidator { 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 CustomerExists(int customerId, CancellationToken cancellationToken) { var customer = await _customerRepository.GetByIdAsync(customerId, cancellationToken); return customer != null; } private async Task ProductExists(OrderItem item, CancellationToken cancellationToken) { var product = await _productRepository.GetByIdAsync(item.ProductId, cancellationToken); return product != null; } } ``` ### External API Validation ```csharp public class ValidateAddressCommandValidator : AbstractValidator { 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 BeValidZipCode(string zipCode, CancellationToken cancellationToken) { return await _validationService.IsValidZipCodeAsync(zipCode, cancellationToken); } private async Task 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { public CreatePlaylistCommandValidator() { RuleFor(x => x.Name) .NotEmpty(); RuleFor(x => x.SongIds) .Must(BeUniqueItems) .WithMessage("Duplicate songs are not allowed"); } private bool BeUniqueItems(List songIds) { return songIds.Distinct().Count() == songIds.Count; } } ``` ## Complex Business Rules ### State Machine Validation ```csharp public class UpdateOrderStatusCommandValidator : AbstractValidator { 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 BeValidStatusTransition( UpdateOrderStatusCommand command, CancellationToken cancellationToken) { var order = await _orderRepository.GetByIdAsync(command.OrderId, cancellationToken); if (order == null) return false; var validTransitions = new Dictionary { ["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 { 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 BeUniqueWithinTenant(string name, CancellationToken cancellationToken) { var tenantId = _tenantContext.TenantId; var existing = await _productRepository.GetByNameAsync(tenantId, name, cancellationToken); return existing == null; } private async Task 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 _mockRepository; private readonly CreateUserCommandValidator _validator; public CreateUserCommandValidatorTests() { _mockRepository = new Mock(); _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())) .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())) .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/)