Documentation

adrs/024-fluentvalidation-strategy.md

ADR-024: FluentValidation Strategy

Status

Accepted

Context

Input validation is critical for API reliability, security, and user experience. We need a consistent approach to validate incoming requests across all Dynaplex services. The validation system must be maintainable, testable, and provide clear error messages.

Options considered:

  • Data Annotations: Simple but limited, mixing validation with models
  • Custom validation: Flexible but requires more code
  • FluentValidation: Separates validation logic, highly testable, fluent API

Decision

We have adopted FluentValidation as the standard validation library across all Dynaplex services, providing a consistent, testable, and maintainable approach to input validation.

Implementation pattern:

public class CreateAssetValidator : AbstractValidator<CreateAssetRequest>
{
    public CreateAssetValidator()
    {
        RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
        RuleFor(x => x.TypeId).GreaterThan(0);
    }
}

Consequences

Positive

  • Separation of Concerns: Validation logic separate from models
  • Testability: Easy to unit test validation rules
  • Fluent API: Readable, chainable validation rules
  • Complex Validation: Supports conditional and async validation
  • Consistent Error Messages: Standardized validation responses
  • Reusability: Validation rules can be composed and reused

Negative

  • Additional Dependency: Another package to maintain
  • Learning Curve: Team must learn FluentValidation patterns
  • Performance: Slight overhead compared to data annotations
  • Boilerplate: Requires validator classes for each request type

Neutral

  • Registration Required: Validators must be registered in DI
  • Integration: Works with minimal APIs and MVC
  • Localization: Supports but requires setup

Implementation Notes

Basic Validator

public class CreateAssetRequest
{
    public string Name { get; set; } = string.Empty;
    public int TypeId { get; set; }
    public string? SerialNumber { get; set; }
    public int LocationId { get; set; }
    public DateTime? PurchaseDate { get; set; }
}

public class CreateAssetValidator : AbstractValidator<CreateAssetRequest>
{
    public CreateAssetValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty().WithMessage("Asset name is required")
            .MaximumLength(100).WithMessage("Asset name cannot exceed 100 characters");
            
        RuleFor(x => x.TypeId)
            .GreaterThan(0).WithMessage("Valid asset type must be selected");
            
        RuleFor(x => x.SerialNumber)
            .MaximumLength(50).When(x => !string.IsNullOrEmpty(x.SerialNumber))
            .WithMessage("Serial number cannot exceed 50 characters");
            
        RuleFor(x => x.LocationId)
            .GreaterThan(0).WithMessage("Valid location must be selected");
            
        RuleFor(x => x.PurchaseDate)
            .LessThanOrEqualTo(DateTime.UtcNow)
            .When(x => x.PurchaseDate.HasValue)
            .WithMessage("Purchase date cannot be in the future");
    }
}

Complex Validation with Dependencies

public class UpdateAssetValidator : AbstractValidator<UpdateAssetRequest>
{
    public UpdateAssetValidator(CatalogDb db)
    {
        RuleFor(x => x.Id)
            .MustAsync(async (id, cancellation) =>
            {
                return await db.Assets.AnyAsync(a => a.Id == id, cancellation);
            })
            .WithMessage("Asset does not exist");
            
        RuleFor(x => x.TypeId)
            .MustAsync(async (typeId, cancellation) =>
            {
                return await db.AssetTypes.AnyAsync(t => t.Id == typeId, cancellation);
            })
            .WithMessage("Invalid asset type");
            
        RuleFor(x => x)
            .MustAsync(async (request, cancellation) =>
            {
                // Check for duplicate serial numbers
                return !await db.Assets.AnyAsync(
                    a => a.SerialNumber == request.SerialNumber && a.Id != request.Id,
                    cancellation);
            })
            .When(x => !string.IsNullOrEmpty(x.SerialNumber))
            .WithMessage("Serial number already exists");
    }
}

Validator Composition

public class AddressValidator : AbstractValidator<Address>
{
    public AddressValidator()
    {
        RuleFor(x => x.Street).NotEmpty().MaximumLength(200);
        RuleFor(x => x.City).NotEmpty().MaximumLength(100);
        RuleFor(x => x.State).NotEmpty().Length(2);
        RuleFor(x => x.ZipCode).Matches(@"^\d{5}(-\d{4})?$");
    }
}

public class CreateLocationValidator : AbstractValidator<CreateLocationRequest>
{
    public CreateLocationValidator()
    {
        RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
        RuleFor(x => x.Address).SetValidator(new AddressValidator());
    }
}

Registration in DI

// Register all validators in assembly
builder.Services.AddValidatorsFromAssemblyContaining<CreateAssetValidator>();

// Or register individually
builder.Services.AddScoped<IValidator<CreateAssetRequest>, CreateAssetValidator>();
builder.Services.AddScoped<IValidator<UpdateAssetRequest>, UpdateAssetValidator>();

Integration with Minimal APIs

app.MapPost("/assets", async (
    CreateAssetRequest request,
    IValidator<CreateAssetRequest> validator,
    IAssetService service) =>
{
    var validation = await validator.ValidateAsync(request);
    if (!validation.IsValid)
    {
        return Results.ValidationProblem(validation.ToDictionary());
    }
    
    var asset = await service.CreateAssetAsync(request);
    return Results.Created($"/assets/{asset.Id}", asset);
});

Endpoint Filter for Automatic Validation

public class ValidationFilter<T> : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterContext context,
        EndpointFilterDelegate next)
    {
        var validator = context.HttpContext.RequestServices
            .GetService<IValidator<T>>();
            
        if (validator is not null)
        {
            var argument = context.Arguments
                .SingleOrDefault(x => x?.GetType() == typeof(T));
                
            if (argument is T typedArgument)
            {
                var validation = await validator.ValidateAsync(typedArgument);
                if (!validation.IsValid)
                {
                    return TypedResults.ValidationProblem(validation.ToDictionary());
                }
            }
        }
        
        return await next(context);
    }
}

// Usage
app.MapPost("/assets", CreateAsset)
    .AddEndpointFilter<ValidationFilter<CreateAssetRequest>>();

Custom Validation Rules

public static class CustomValidators
{
    public static IRuleBuilderOptions<T, string> ValidEmail<T>(
        this IRuleBuilder<T, string> ruleBuilder)
    {
        return ruleBuilder
            .EmailAddress()
            .Must(email => !email.EndsWith("@example.com"))
            .WithMessage("Example.com email addresses are not allowed");
    }
    
    public static IRuleBuilderOptions<T, string> ValidPhoneNumber<T>(
        this IRuleBuilder<T, string> ruleBuilder)
    {
        return ruleBuilder
            .Matches(@"^\+?1?\d{10,14}$")
            .WithMessage("Invalid phone number format");
    }
}

// Usage
public class UserValidator : AbstractValidator<User>
{
    public UserValidator()
    {
        RuleFor(x => x.Email).ValidEmail();
        RuleFor(x => x.Phone).ValidPhoneNumber();
    }
}

Testing Validators

[TestFixture]
public class CreateAssetValidatorTests
{
    private CreateAssetValidator _validator;
    
    [SetUp]
    public void Setup()
    {
        _validator = new CreateAssetValidator();
    }
    
    [Test]
    public async Task Validate_ValidRequest_PassesValidation()
    {
        // Arrange
        var request = new CreateAssetRequest
        {
            Name = "Laptop",
            TypeId = 1,
            LocationId = 1
        };
        
        // Act
        var result = await _validator.ValidateAsync(request);
        
        // Assert
        result.IsValid.Should().BeTrue();
    }
    
    [Test]
    public async Task Validate_EmptyName_FailsValidation()
    {
        // Arrange
        var request = new CreateAssetRequest
        {
            Name = "",
            TypeId = 1,
            LocationId = 1
        };
        
        // Act
        var result = await _validator.ValidateAsync(request);
        
        // Assert
        result.IsValid.Should().BeFalse();
        result.Errors.Should().Contain(e => e.PropertyName == "Name");
    }
}

Standard Error Response

public static class ValidationExtensions
{
    public static IDictionary<string, string[]> ToDictionary(
        this ValidationResult validationResult)
    {
        return validationResult.Errors
            .GroupBy(x => x.PropertyName)
            .ToDictionary(
                g => g.Key,
                g => g.Select(x => x.ErrorMessage).ToArray()
            );
    }
}

Best Practices

  1. One validator per request type for clarity
  2. Compose validators for complex objects
  3. Use async validation for database checks
  4. Provide clear error messages for each rule
  5. Test all validation paths including edge cases
  6. Cache validators as singletons when possible
  7. Use validation filters for consistent application
  • ADR-013: Minimal APIs with Static Classes (validation integration)
  • ADR-014: TypedResults for Type-Safe Responses (validation problem responses)