Ever since I wrote about the “Is One Of” and “Is Not One Of” validation attributes, I've wondered if there is a better approach to class-level validation. Validating individual properties is fine, but the more interesting scenarios involve validating combinations of members.
Enter FluentValidation:
public class CustomerValidator : AbstractValidator<Customer>
{
public CustomerValidator()
{
RuleFor(x => x.Surname).NotEmpty();
RuleFor(x => x.Forename).NotEmpty().WithMessage("Please specify a first name");
RuleFor(x => x.Discount).NotEqual(0).When(x => x.HasDiscount);
RuleFor(x => x.Address).Length(20, 250);
RuleFor(x => x.Postcode).Must(BeAValidPostcode).WithMessage("Please specify a valid postcode");
}
private bool BeAValidPostcode(string postcode)
{
// custom postcode validating logic goes here
}
}Wow, this makes the intent really clear.
Some observations
When I compare FluentValidation to validation by data annotation, I observe the following:
- Discovery: data annotations are very clear. Everything is part of the class that needs to be validated. With a separate validator class, we lose that clarity. Sure, the validator is tied to the class, but from the perspective of the class we can't see it.
- Validation mechanism: It uses a different validation mechanism — the FluentValidation.AspNetCore integration package is deprecated, but you can do manual validation using an
IValidator. - OpenAPI. Data annotations like
RequiredandMinLengthare parsed into the OpenAPI specification. I'd rather use[Required]thanRuleFor(x => x.SurName).NotEmpty(). - Complex validation based on other members. FluentValidation really shines when multiple members and conditions are involved:
RuleFor(x => x.EntryPoint)
.NotEmpty()
.When(
x => x.Type == ApplicationType.ApplicationWithEntryPoint,
ApplyConditionTo.CurrentValidator)
.Empty()
.When(
x => x.Type == ApplicationType.Application,
ApplyConditionTo.CurrentValidator);So what if I told you that we can connect FluentValidation to data annotation validation?
IValidatableObject to the rescue
The IValidatableObject can be used to implement the validation of the entire class. Let's combine them inside the Validate method using an InlineValidator:
public class SimpleApplication : IValidatableObject
{
[Required, MinLength(5), ApplicationNameAvailable]
public string Name { get; set; } = string.Empty;
public ApplicationType Type { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public string? EntryPoint { get; set; }
public int MagicNumber { get; set; }
[Label]
public string Label { get; set; } = string.Empty;
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
var v = new InlineValidator<SimpleApplication>();
// conditional validation
v.RuleFor(x => x.EntryPoint)
.NotEmpty()
.When(
x => x.Type == ApplicationType.ApplicationWithEntryPoint,
ApplyConditionTo.CurrentValidator)
.Empty()
.When(
x => x.Type == ApplicationType.Application,
ApplyConditionTo.CurrentValidator);
// dependency injection
var provider = validationContext.GetRequiredService<IMagicNumberProvider>();
v.RuleFor(x => x.MagicNumber)
.MustAsync(async (magicNumber, _) => magicNumber == await provider.GetMagicNumber())
.WithMessage("Magic number is invalid.");
var result = v.ValidateAsync(this).Result;
var errors = result.Errors.Select(e => new ValidationResult(e.ErrorMessage, [e.PropertyName]));
return errors;
}
}
Here we see a combination of attribute annotations (Required, MinLength, etc) combined with an InlineValidator that is triggered on Validate. This works like a charm. Here's a test that proves it works:
[Fact]
public void ValidateByValidatorWithServiceProvider()
{
// arrange
FluentValidationLanguageManager.SetGlobalOptions();
var provider = new ServiceCollection()
.AddSingleton<IMagicNumberProvider, MagicNumberProvider>()
.AddSingleton<IDataAnnotationsValidator, DataAnnotationsValidator>()
.AddSingleton<ProvisionerService>()
.AddSingleton(sp => new ProvisioningOptions
{
Labels = ["development", "production"]
})
.AddTransient(sp => Options.Create(sp.GetRequiredService<ProvisioningOptions>()))
.AddSingleton(sp => sp)
.BuildServiceProvider();
var obj = new SimpleApplication
{
Name = "My Application",
Type = ApplicationType.Application,
EntryPoint = "dotnet run kaas.is.lekker.dll",
MagicNumber = 1337,
Label = "development"
};
// act
IList<ValidationResult> validationErrors = [];
var context = new ValidationContext(obj, provider, null);
var valid = Validator.TryValidateObject(obj, context, validationErrors, true);
// assert
valid.Should().BeFalse();
validationErrors.Should().NotBeNullOrEmpty();
validationErrors.Should().HaveCount(2);
var messages = validationErrors.Select(e => e.ErrorMessage).ToList();
messages.Should().Contain("EntryPoint must be empty.");
messages.Should().Contain("Magic number is invalid.");
} A note on localization
The Empty() and NotEmpty() validators will generate error messages that include the property name in quotes. The [Required] attribute will not. FluentValidation will also split the property name — EntryPoint becomes Entry Point in the message.
The [Required] attribute will say something like EntryPoint field is required.; it will add the word field — another difference to account for.
To harmonize things, we should use a custom language manager.
public class FluentValidationLanguageManager : LanguageManager
{
private FluentValidationLanguageManager()
{
}
public override string? GetString(string key, CultureInfo? culture = null)
{
var message = base.GetString(key, culture);
// Harmonize output with attribute validation
return message?.Replace("'{PropertyName}'", "{PropertyName}");
}
public static void SetGlobalOptions()
{
ValidatorOptions.Global.LanguageManager = new FluentValidationLanguageManager();
ValidatorOptions.Global.DisplayNameResolver = (type, member, expression) =>
{
return member?.Name;
};
}
}
I hate (!!!) doing this, as this fiddles around with static properties, which may be set and unset at will. To group things together I create a single static method, which makes it easier. Now we need to add FluentValidationLanguageManager.SetGlobalOptions(); to the ConfigureServices of the API. You also need to trigger this in your tests.
Drawback
The main (theoretical) drawback of this approach is the InlineValidator. It will be constructed every time we validate the object. For small validations, that won't be a problem, but if you have large object graphs that need to be validated, it might increase your footprint a bit.
Final thoughts
I'm going to use FluentValidation with the InlineValidator in my projects. I currently use my own DataAnnotationsValidator to inject the validator into my business services. FluentValidation also provides some DI capabilities, so I'll check that out in the future.
The code is on GitHub, so check it out: code gallery / 14. validation.
Changelog
- Updated the project to .NET 10 and upgraded all packages (Swashbuckle 10.x, FluentValidation 12.1.1, etc.).
- renamed the object.
- added the section on localization.
- initial article.