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
- One validator per request type for clarity
- Compose validators for complex objects
- Use async validation for database checks
- Provide clear error messages for each rule
- Test all validation paths including edge cases
- Cache validators as singletons when possible
- Use validation filters for consistent application
Related ADRs
- ADR-013: Minimal APIs with Static Classes (validation integration)
- ADR-014: TypedResults for Type-Safe Responses (validation problem responses)