Let’s combine Data Annotation Validation with FluentValidation

Let’s combine Data Annotation Validation with FluentValidation

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:

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 Validate(ValidationContext validationContext)
    {
        var v = new InlineValidator();

        // 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();
        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()
        .AddSingleton()
        .AddSingleton()
        .AddSingleton(sp => new ProvisioningOptions
        {
            Labels = ["development", "production"]
        })
        .AddTransient(sp => Options.Create(sp.GetRequiredService()))
        .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 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