There are many rules around why you should do model validation but the how is less of a concern, as long as it gets done. Use the tools that get the job done, we don’t need a prescription for everything we do. But there is a caveat…

Ensure this will get the job done without needing to be replaced in the far future.

This can be doubly true for validation libraries that we tend to ingrain in our codebase. Let’s be honest, most people writing apps in dotnet will be likely to use the default Model Validation that comes out the box, and why shouldn’t they? It’s pretty great! It does however rely on Data Annotations and IValidationObject and that brings some shortcomings.

Model Validation Shortcomings


  1. Limited Expressiveness
    • Data annotations ([Required], [MaxLength], [Range], etc.) are great for simple checks, but they struggle with complex validation logic.
    • For example, you cannot easily express:
      • Conditional validation (FieldA is required only if FieldB has a certain value).
      • Validation that depends on multiple fields.
      • Custom business rules that involve external data sources.
  2. Violation of the Single Responsibility Principle (SRP)
    • Validation logic is tied directly to your model via attributes.
    • This means your data models get cluttered with validation rules, making them harder to maintain and reuse.
  3. Lack of Fluent API / Composability
    • The annotation-based approach doesn’t allow for easy reuse of validation rules.
    • If multiple models need similar validation logic, you’ll often have to duplicate annotations or create custom attributes.
  4. No Custom Error Messages for Complex Logic
    • Data annotations allow custom error messages, but if your logic requires more dynamic messages (e.g., “You can only register between 9 AM and 5 PM”), you have to override IValidatableObject.
  5. Hard to Test Separately
    • Since validation is baked into models, unit testing is harder.
    • You need to create actual model instances and trigger validation manually.
  6. No Dependency Injection Support
    • If your validation needs external services (e.g., checking if an email is already registered in a database), Data Annotations don’t support dependency injection.
  7. Limited Asynchronous Validation
    • Data annotations and IValidatableObject don’t support async validation, which is crucial for checking data in external services.

In comes Fluent Validation!

Why Fluent Validation?


Fluent validation solves the above issues by:

  • Providing a fluent API for writing readable and maintainable validation rules.
  • Decoupling validation from models, keeping the code clean and SRP-compliant.
  • Allowing dependency injection, enabling validations that need external data.
  • Supporting conditional, composable, and reusable rules effortlessly.
  • Allowing async validation, making external data checks possible.
  • Simplifying unit testing, since validators are separate classes.

If you’re like me, you skim through technical documentation trying to find the code, because that’s the language you speak best. With that in mind, let’s look at some fairly simple examples of how to apply fluent validation.

Code Examples


Let’s pretend we’re a payment processor, and we have an endpoint that accepts a payment request which we’ll save in our database.

If you’ve ever worked with payment providers you’ll know that they are like snowflakes, in that they are many, they are each unique, and your interactions with them are fragile - especially given that you’re working with someone else’s money.

Payment Controller

First we have our payment controller. It’s fairly standard, accepts a payment, validates it, saves it, returns a 204.

[Controller]
[Route("[controller]")]
public class PaymentController : Controller
{
    [HttpPost("SavePayment")]
    public IActionResult SavePayment(Payment payment)
    {
        if (ModelState.IsValid == false)
            return BadRequest(ModelState);
        
        // Save that payment!
        return NoContent();
    }
}

That’s easy enough, but what are we validating against exactly?

Payment Class

public class Payment
{
    public PaymentType PaymentType { get; set; }
    public string CardNumber { get; set; }
    public string CardHolder { get; set; }
    public string ExpirationDate { get; set; }
    public string CVV { get; set; }
    [Required]
    public decimal Amount { get; set; }
    [Required]
    public string Currency { get; set; }
    public string ProviderId { get; set; }
    public string ProviderCorrelationId { get; set; }
}

public enum PaymentType
{
    CreditCard,
    PayPal,
    ApplePay,
    GooglePay
}

Here we have a simple class with an enum PaymentType. Given the variety of payment types, we can only really validate against Amount and Currency. The reality is that Credit Cards would need CardNumber, CardHolder, ExpirationDate, and CVV validated as well. Similarly, PayPal, Apple Pay, and GooglePay, would all required differing validations as well.

It could be argued that we should be creating derivatives and validating them all separately but let’s see if there is a different approach to keep things more simple while future proofing ourselves.

Adding Fluent Validation

First thing first, let’s add the library to our project:

<PackageReference Include="FluentValidation" Version="12.0.0-preview1" />

This should not be mistaken with FluentAssertions which is a different library more focused around TDD and BDD, one we’ll touch on in a different article.

That’s it, now we’ve got it added. Let’s create our first validator.

Basic Validator

We create a new PaymentValidator class and implement it as follows:

public class PaymentValidator : AbstractValidator<Payment>
{
    public PaymentValidator()
    {
        RuleFor(x => x.Amount).GreaterThan(0);
        RuleFor(x => x.Currency).NotEmpty();
    }
}

Seems simple enough but it also, currently, seems like a long way around to what we had in our validation attributes originally. This is compounded more so when we do the actual validation in PaymentController:

[Controller]
[Route("[controller]")]
public class PaymentController : Controller
{
    [HttpPost("SavePayment")]
    public IActionResult SavePayment(Payment payment)
    {
        var validator = new PaymentValidator();
        var results = validator.Validate(payment);
        if (results.IsValid == false)
        {
            return BadRequest(results.Errors);
        }
        
        // Save that payment!

        return NoContent();
    }
}

This looks decent, but it does look more complex than simply adding some attributes, and it’s about this time that I’d like to caveat something.

If you’re only validating a very simple and straightforward model, you don’t need fluent validation.

The validation looks great, and when we get to chaining and conditional validation it’s usefulness really shines but don’t create complexity where it’s not needed.

Chained Validation

Let’s look at how we can chain commands to improve our validation by updating PaymentValidator:

public class PaymentValidator : AbstractValidator<Payment>
{
    private static readonly string[] AllowedCurrencies = ["USD", "GBP", "EUR"];

    public PaymentValidator()
    {
        RuleFor(x => x.Amount).GreaterThan(0);
        
        RuleFor(x => x.Currency)
            .NotEmpty()
            .Must(currency => AllowedCurrencies.Contains(currency))
            .WithMessage($"Currency must be one of the following: {AllowedCurrencies}");
    }
}

Just like that, we’re validating that our Currency field is not only a required field, but it’s limited to certain currencies.

Dependency Injection and Async Validation

Let’s say that we want to ensure our currencies exist within our currencies table before we go trying to ham something new in there. Thanks to dependency injection and async validation, we can do just that.

First, we register our validator in our IoC Container:

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddControllers();
builder.Services.AddScoped<IValidator<Payment>, PaymentValidator>(); // <-- Easy as

This allows us to inject other services into our validator as below:

public class PaymentValidator : AbstractValidator<Payment>
{
    private readonly ICurrencyService _currencyService;

    public PaymentValidator(ICurrencyService currencyService)
    {
        RuleFor(x => x.Amount).GreaterThan(0);
        
        RuleFor(x => x.Currency)
            .NotEmpty()
            .MustAsync(IsValidCurrencyAsync)
            .WithMessage("Currency must be one of the following: USD, GBP, EUR.");
    }

    private async Task<bool> IsValidCurrencyAsync(string currency, CancellationToken cancellationToken)
    {
        var allowedCurrencies = await _currencyService.GetAllowedCurrenciesAsync();
        return allowedCurrencies.Contains(currency);
    }
}

No more making additional calls, validating, checking, and then continuing further down the chain. Let our validation take care of it for us!

Conditional Validation

We might need to validate our model differently based on the fields that exist in there. The easiest way to think of this is if you had a Customer model that needed either a Telephone or Cellphone or both. You couldn’t simply mark both as [Required].

Let’s see how we apply conditional validation to our PaymentValidator:

public class PaymentValidator : AbstractValidator<Payment>
{
    private readonly ICurrencyService _currencyService;

    public PaymentValidator(ICurrencyService currencyService)
    {
        _currencyService = currencyService;

        RuleFor(x => x.Amount).GreaterThan(0);
        
        RuleFor(x => x.Currency)
            .NotEmpty()
            .MustAsync(IsValidCurrencyAsync)
            .WithMessage("Currency must be one of the following: USD, GBP, EUR.");

        // Validation for Credit Card payments
        When(x => x.PaymentType == PaymentType.CreditCard, () =>
        {
            RuleFor(x => x.CardNumber)
                .NotEmpty()
                .CreditCard() // This is a built in validator
                .WithMessage("Invalid or missing card number.");

            RuleFor(x => x.CardHolder)
                .NotEmpty()
                .WithMessage("Card holder name is required.");

            RuleFor(x => x.ExpirationDate)
                .NotEmpty()
                .Matches(@"^(0[1-9]|1[0-2])\/([0-9]{2})$")
                .WithMessage("Expiration date must be in MM/YY format.");

            RuleFor(x => x.CVV)
                .NotEmpty()
                .Matches(@"^\d{3,4}$")
                .WithMessage("CVV must be 3 or 4 digits.");
        });

        // Validation for PayPal, Apple Pay, Google Pay
        When(x => x.PaymentType == PaymentType.PayPal 
                || x.PaymentType == PaymentType.ApplePay 
                || x.PaymentType == PaymentType.GooglePay, () =>
        {
            RuleFor(x => x.ProviderId)
                .NotEmpty()
                .WithMessage("ProviderId is required for this payment type.");
        });
    }

    private async Task<bool> IsValidCurrencyAsync(string currency, CancellationToken cancellationToken)
    {
        var allowedCurrencies = await _currencyService.GetAllowedCurrenciesAsync();
        return allowedCurrencies.Contains(currency);
    }
}

This is great, we’re able to take a single model and validate it accordingly before performing any business logic.

Conclusion


Fluent Validation presents a powerful way to improve the way we validate our models. All this without interfering with our models themselves. No need for validation attributes littered around. Though, I suppose it could be argued that we’ll now have Validation classes littering our projects. I’ll say it again, it all comes down to the requirements.

I’ve only touched on some of its capabilities here, but it’s already quite powerful, and I’d recommend it to anyone looking to do more advanced validation.