Let’s combine Data Annotation Validation with FluentValidation

Ever since I wrote the blog “Is One Of” and “Is Not One Of” validation attributes, I've wondered if there is anything better for class validation. I mean: validating individual properties is fine, but usually the more interesting stuff is the validation of a combination 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 intent really 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 the example implementation, we loose 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 has a different validation mechanism, we need to do something to hook it up in the ASP.NET API validation pipeline, but that is deprecated. We can do some manual validation using an IValidator, but that's a bit verbose.
  • OpenAPI. Data annotations like Required and MinLength are parsed into the OpenAPI specification. I would rather use [Required] than RuleFor(x => x.SurName).NotEmpty().
  • Complex validation bases 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 the FluentValidations to the data validation?

IValidatableObject to the rescue

The IValidatableObject can be used to implement the validation of the entire class. Let's combine them with

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 attribute annotations (Required, MinLength, etc) combined with an InlineValidator that is triggered on the Validate. This works like a charm. Everything gets combined when we validate. Check the following test:

[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() validations will generate messages with quoted property names. The [Required] will not do so. FluentValidation will also split the name of the property: EntryPoint becomes Entry Point in the message.

The [Required] will says something like EntryPoint field is required.; it will add the word field, which is also a difference.

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 way of working, is the InlineValidator. It will be constructed every time we're validating the object. For small validations, that won't be a problem, but if you have huge trees of objects that need to be validated, it might increase your footprint a bit.

Conclusion

I think I'm going to use FluentAssertions with the InlineValidator in my projects. I currently use my own DataAnnotationsValidator to inject the validator into my business services. Now, the FluentAssertions lib also provides some DI capabilities, so I will check that out in the future.

The code is on GitHub, so check it out: code gallery / 14. validation.

Changelog

expand_less brightness_auto