dotnet-cqrs/docs/core-features/validation/custom-validation.md

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

See Also