FluentValidation Integration
The UnionGenerator.FluentValidation package provides seamless integration with FluentValidation, making it easy to map validation results to union error types.
Overview
This integration enables:
- Automatic conversion from ValidationResult to union types
- Property path mapping to error dictionaries
- Error aggregation
- Type-safe validation error handling
- Integration with ASP.NET Core model validation
Installation
dotnet add package UnionGenerator.FluentValidation
Requirements:
- .NET 6.0 or later
- FluentValidation 11.0 or later
- UnionGenerator package
Quick Start
Define Validation Result Union
[GenerateUnion]
public partial record ValidationResult<T>
{
public static partial ValidationResult<T> Valid(T value);
public static partial ValidationResult<T> Invalid(Dictionary<string, string[]> errors);
}
Create Validator
using FluentValidation;
public class CreateUserDto
{
public string Email { get; set; }
public string Password { get; set; }
public int Age { get; set; }
}
public class CreateUserValidator : AbstractValidator<CreateUserDto>
{
public CreateUserValidator()
{
RuleFor(x => x.Email)
.NotEmpty().WithMessage("Email is required")
.EmailAddress().WithMessage("Invalid email format");
RuleFor(x => x.Password)
.NotEmpty().WithMessage("Password is required")
.MinimumLength(8).WithMessage("Password must be at least 8 characters");
RuleFor(x => x.Age)
.GreaterThanOrEqualTo(18).WithMessage("Must be 18 or older");
}
}
Validate and Convert
public class UserService
{
private readonly IValidator<CreateUserDto> _validator;
public ValidationResult<User> CreateUser(CreateUserDto dto)
{
var validationResult = _validator.Validate(dto);
if (!validationResult.IsValid)
{
var errors = validationResult.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(
g => g.Key,
g => g.Select(e => e.ErrorMessage).ToArray()
);
return ValidationResult<User>.Invalid(errors);
}
var user = new User
{
Email = dto.Email,
// ... create user
};
return ValidationResult<User>.Valid(user);
}
}
Extension Methods
Create reusable extension methods:
using FluentValidation.Results;
public static class ValidationExtensions
{
public static ValidationResult<T> ToUnionResult<T>(
this FluentValidation.Results.ValidationResult result,
T value)
{
if (result.IsValid)
{
return ValidationResult<T>.Valid(value);
}
var errors = result.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(
g => g.Key,
g => g.Select(e => e.ErrorMessage).ToArray()
);
return ValidationResult<T>.Invalid(errors);
}
public static ValidationResult<T> ValidateAndCreate<T>(
this IValidator<T> validator,
T value)
{
var result = validator.Validate(value);
return result.ToUnionResult(value);
}
}
// Usage
public ValidationResult<User> CreateUser(CreateUserDto dto)
{
var result = _validator.Validate(dto);
if (!result.IsValid)
{
return result.ToUnionResult<User>(null!);
}
var user = CreateUserFromDto(dto);
return ValidationResult<User>.Valid(user);
}
ASP.NET Core Integration
Controller Action Filter
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
public class ValidateModelAttribute : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext context)
{
if (!context.ModelState.IsValid)
{
var errors = context.ModelState
.Where(x => x.Value.Errors.Any())
.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value.Errors.Select(e => e.ErrorMessage).ToArray()
);
context.Result = new BadRequestObjectResult(new ValidationProblemDetails(errors));
}
}
}
// Usage
[ApiController]
[Route("api/users")]
public class UsersController : ControllerBase
{
[HttpPost]
[ValidateModel]
public IActionResult Create(CreateUserDto dto)
{
var result = _service.CreateUser(dto);
return result.Match(
valid => CreatedAtAction(nameof(Get), new { id = valid.Value.Id }, valid.Value),
invalid => BadRequest(new ValidationProblemDetails(invalid.Errors))
);
}
}
Minimal API
app.MapPost("/api/users", async (
CreateUserDto dto,
IValidator<CreateUserDto> validator,
IUserService service) =>
{
var result = validator.ValidateAndCreate(dto);
if (result.IsInvalid)
{
return Results.BadRequest(result.Match(
valid => null,
invalid => new { Errors = invalid.Errors }
));
}
var user = await service.CreateUserAsync(dto);
return Results.Created($"/api/users/{user.Id}", user);
});
Complex Validation Scenarios
Async Validation
public class CreateUserValidator : AbstractValidator<CreateUserDto>
{
private readonly IUserRepository _repository;
public CreateUserValidator(IUserRepository repository)
{
_repository = repository;
RuleFor(x => x.Email)
.NotEmpty()
.EmailAddress()
.MustAsync(BeUniqueEmail)
.WithMessage("Email already exists");
}
private async Task<bool> BeUniqueEmail(string email, CancellationToken cancellation)
{
return !await _repository.EmailExistsAsync(email, cancellation);
}
}
// Usage
public async Task<ValidationResult<User>> CreateUserAsync(CreateUserDto dto)
{
var validationResult = await _validator.ValidateAsync(dto);
if (!validationResult.IsValid)
{
var errors = validationResult.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return ValidationResult<User>.Invalid(errors);
}
var user = await CreateUserFromDtoAsync(dto);
return ValidationResult<User>.Valid(user);
}
Conditional Validation
public class OrderValidator : AbstractValidator<Order>
{
public OrderValidator()
{
RuleFor(x => x.CustomerName)
.NotEmpty()
.When(x => x.OrderType == OrderType.Retail);
RuleFor(x => x.CompanyName)
.NotEmpty()
.When(x => x.OrderType == OrderType.Corporate);
RuleFor(x => x.Items)
.NotEmpty()
.WithMessage("Order must contain at least one item");
RuleForEach(x => x.Items)
.SetValidator(new OrderItemValidator());
}
}
Custom Error Codes
[GenerateUnion]
public partial record ValidationResult<T>
{
public static partial ValidationResult<T> Valid(T value);
public static partial ValidationResult<T> Invalid(
Dictionary<string, ValidationError[]> errors
);
}
public record ValidationError(string Code, string Message);
public class CreateUserValidator : AbstractValidator<CreateUserDto>
{
public CreateUserValidator()
{
RuleFor(x => x.Email)
.NotEmpty()
.WithErrorCode("EMAIL_REQUIRED")
.WithMessage("Email is required");
RuleFor(x => x.Email)
.EmailAddress()
.WithErrorCode("EMAIL_INVALID")
.WithMessage("Invalid email format");
}
}
// Conversion
public static ValidationResult<T> ToUnionResult<T>(
this FluentValidation.Results.ValidationResult result,
T value)
{
if (result.IsValid)
{
return ValidationResult<T>.Valid(value);
}
var errors = result.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(
g => g.Key,
g => g.Select(e => new ValidationError(e.ErrorCode, e.ErrorMessage)).ToArray()
);
return ValidationResult<T>.Invalid(errors);
}
Testing Validation
public class CreateUserValidatorTests
{
private readonly CreateUserValidator _validator = new();
[Fact]
public void Validate_EmptyEmail_ReturnsInvalid()
{
var dto = new CreateUserDto { Email = "", Password = "password123", Age = 25 };
var result = _validator.ValidateAndCreate(dto);
Assert.True(result.IsInvalid);
result.Match(
valid => Assert.Fail("Expected invalid result"),
invalid =>
{
Assert.Contains("Email", invalid.Errors.Keys);
Assert.Contains("required", invalid.Errors["Email"][0], StringComparison.OrdinalIgnoreCase);
}
);
}
[Fact]
public void Validate_ValidData_ReturnsValid()
{
var dto = new CreateUserDto
{
Email = "user@example.com",
Password = "password123",
Age = 25
};
var result = _validator.ValidateAndCreate(dto);
Assert.True(result.IsValid);
}
[Fact]
public void Validate_MultipleErrors_ReturnsAllErrors()
{
var dto = new CreateUserDto { Email = "", Password = "123", Age = 15 };
var result = _validator.ValidateAndCreate(dto);
result.Match(
valid => Assert.Fail("Expected invalid result"),
invalid =>
{
Assert.True(invalid.Errors.Count >= 3);
Assert.Contains("Email", invalid.Errors.Keys);
Assert.Contains("Password", invalid.Errors.Keys);
Assert.Contains("Age", invalid.Errors.Keys);
}
);
}
}
Best Practices
Separate Validation Logic
// ✅ Keep validation rules in validators
public class CreateUserValidator : AbstractValidator<CreateUserDto>
{
public CreateUserValidator()
{
RuleFor(x => x.Email).NotEmpty().EmailAddress();
RuleFor(x => x.Password).MinimumLength(8);
}
}
// ✅ Keep business logic in services
public class UserService
{
public ValidationResult<User> CreateUser(CreateUserDto dto)
{
var validationResult = _validator.Validate(dto);
if (!validationResult.IsValid)
{
return ConvertToUnionResult(validationResult);
}
// Business logic here
var user = CreateUserFromDto(dto);
return ValidationResult<User>.Valid(user);
}
}
Reusable Error Mapping
public static class ValidationResultMapper
{
public static Dictionary<string, string[]> ToErrorDictionary(
this FluentValidation.Results.ValidationResult result)
{
return result.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(
g => g.Key,
g => g.Select(e => e.ErrorMessage).ToArray()
);
}
}
Dependency Injection
// Startup.cs or Program.cs
services.AddValidatorsFromAssemblyContaining<CreateUserValidator>();
// Automatic validation in ASP.NET Core
services.AddFluentValidationAutoValidation();
Key Takeaways
✅ Extension methods simplify validation result conversion
✅ Type safety ensures all validation errors are handled
✅ ASP.NET Core integration works seamlessly
✅ Testable validation logic
✅ Reusable patterns across application