# Let&#8217;s combine Data Annotation Validation with FluentValidation

**Date:** 2025-04-06  
**Author:** Kees C. Bakker  
**Categories:** .NET / C#  
**Tags:** .NET Data Validation  
**Original:** https://keestalkstech.com/lets-combine-data-annotation-validation-with-fluentvalidation/

![Let&#8217;s combine Data Annotation Validation with FluentValidation](https://keestalkstech.com/wp-content/uploads/2025/04/chuttersnap-G8ioIHUDfNc-unsplash.jpg)

---

Ever since I wrote about the [“Is One Of” and “Is Not One Of” validation attributes](https://keestalkstech.com/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](https://docs.fluentvalidation.net/en/latest/index.html#example):

```csharp
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](https://docs.fluentvalidation.net/en/latest/aspnet.html#using-the-asp-net-validation-pipeline) integration package is deprecated, but you can do [manual validation](https://docs.fluentvalidation.net/en/latest/aspnet.html#manual-validation) using an `IValidator`.
- **OpenAPI**. Data annotations like `Required` and `MinLength` are parsed into the OpenAPI specification. I'd rather use `[Required]` than `RuleFor(x => x.SurName).NotEmpty()`.
- **Complex validation based on other members**. FluentValidation really shines when multiple members and [conditions](https://docs.fluentvalidation.net/en/latest/conditions.html) are involved:

```csharp
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](https://keestalkstech.com/data-annotation-validation-in-a-business-service/#dataannotationsvalidator-implementation) 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](https://github.com/KeesCBakker/keestalkstech-code-gallery/tree/main/14.validation).

## Changelog

- 2026-07-21: Updated the project to .NET 10 and upgraded all packages (Swashbuckle 10.x, FluentValidation 12.1.1, etc.).
- 2025-05-27: renamed the object.
- 2025-05-17: added the [section on localization](#a-note-on-localization).
- 2025-04-06: initial article.
