16 KiB
16 KiB
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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