Updated on

FluentValidation validates .NET objects with rules written in code rather than attributes on the model. We declare a class deriving from AbstractValidator<T> and add one RuleFor() chain per property.

The library ships validators for the cases we hit constantly: NotEmpty(), Length(), InclusiveBetween(), EmailAddress(), IsInEnum(), and Must() for anything it does not cover. This article works through each of them, then builds a custom validator of our own.

This picks up where our introduction to FluentValidation in ASP.NET Core left off, but the concepts here apply to any .NET application, not just ASP.NET Core applications. To demonstrate that, we are going to build all our code in a .NET class library, and then pull that into an ASP.NET Core API to see the end result.

To download the source code for this article, you can visit the FluentValidation Validators repository.

What Is FluentValidation?

FluentValidation is an open-source .NET library for validating objects with rules defined in a separate class instead of attributes on the model.

We create a validator by deriving from AbstractValidator<T> and writing one RuleFor() chain per property in the constructor. Calling Validate() returns a ValidationResult carrying IsValid and a list of failures; ValidateAsync() does the same for rules that hit a database or an API.

The reason to prefer it over data annotations is that the rules are ordinary C# in an ordinary class. They can depend on injected services, branch on the state of the object, cover a property differently in two contexts, and be unit tested without constructing a request.

The cost is that nothing validates automatically. Something has to call the validator: a filter, a MediatR pipeline behaviour, or the handler itself.

Project Setup

Let’s start by creating a new class library. In Visual Studio, that is the Class Library template, which targets .NET or .NET Standard:

Next, let’s go ahead and install FluentValidation:

dotnet add package FluentValidation

Now, let’s add a new OrderStatus enumeration to the project:

public enum OrderStatus
{
    Accepted,
    Processing,
    Complete
}

Furthermore, we are going to add a class called Product:

public class Product
{
    public string Name { get; set; }
}

Next, let’s add a class called Order:

public class Order
{
    public string CustomerName { get; set; }
    public int Price { get; set; }
    public string CustomerEmail { get; set; }
    public OrderStatus OrderStatus { get; set; }
    public Product Product { get; set; }
}

Finally, let’s set up our validator:

public class OrderValidator : AbstractValidator<Order>
{
    public OrderValidator()
    {

    }
}

ASP.NET Core API

To test things out, we are going to add a new ASP.NET Core API project and reference the class library.

Let’s select “Add > New Project” from the Solution Explorer, and pick the ASP.NET Core Web API template:

Let’s accept all the defaults, and then add a reference to our ClassLibrary1 project from WebApplication1:

dotnet add WebApplication1 reference ClassLibrary1

Let’s right-click on WebApplication1 and select “Set as Startup Project” to make our API the default startup project:

Validators with FluentValidation - Setting an API As a Startup Project

Right after that, we are going to hit the “CTRL-F5” shortcut to start our API without debugging, and we should see the familiar weather forecast result displayed in our default browser:

Sample API Response

Setting up the Controller

Now that we have a basic ASP.NET Core API set up with our project referenced, we need to add a controller to accept an Order object.

Let’s add an empty API controller to the Controllers folder and name it OrdersController, so it represents the resource we are going to interact with.

In the created OrdersController, let’s add a using statement to reference our ClassLibrary:

using ClassLibrary1;

Next, let’s add the following method:

[HttpPost]
public ActionResult Post([FromBody] Order order)
{
    return Ok("Success!");
}

This method is identical to our previous article, in which we are simply accepting an object in the request body from a POST request, and returning a 200 (OK) response with the text “Success!”.

Wiring up FluentValidation

Now, we need to make our validators available to the API. To do that, let’s install the dependency injection package into our WebApplication1 project:

dotnet add package FluentValidation.DependencyInjectionExtensions

Then, let’s open up Program.cs and add the necessary using statements:

using ClassLibrary1;
using FluentValidation;

Additionally, we have to register our validators with the DI container:

builder.Services.AddControllers();
builder.Services.AddValidatorsFromAssemblyContaining<OrderValidator>();

This registers the OrderValidator class we created previously as IValidator<Order>, along with every other validator in the same assembly, so we can inject any of them wherever we need to validate.

There is an older path here that deserves a mention, because plenty of existing code uses it. The FluentValidation.AspNetCore package plugs into ASP.NET Core’s validation pipeline and runs our validators automatically before the action executes, populating ModelState the way ModelState validation does for data annotations. FluentValidation’s own ASP.NET Core documentation is blunt about it now: “We no longer recommend using this approach for new projects but it is still available for legacy implementations.” That package also stops at version 11.3.1, so it cannot be paired with FluentValidation 12 at all.

So we call the validator ourselves. Let’s inject it into OrdersController and validate the incoming order:

private readonly IValidator<Order> _validator;

public OrdersController(IValidator<Order> validator)
{
    _validator = validator;
}

[HttpPost]
public async Task<ActionResult> Post([FromBody] Order order)
{
    var validationResult = await _validator.ValidateAsync(order);

    if (!validationResult.IsValid)
    {
        foreach (var error in validationResult.Errors)
        {
            ModelState.AddModelError(error.PropertyName, error.ErrorMessage);
        }

        return ValidationProblem(ModelState);
    }

    return Ok("Success!");
}

Three lines of plumbing buy us a 400 (Bad Request) with a standard problem-details payload, and the validation happens where we can see it. The same shape works for validating a minimal API endpoint, where there is no controller to hook into at all.

Let’s open up Postman and make sure everything is working, by adding the POST request:

Postman request - Testing validators with FluentValidation

If we hit the Send button, we should see a 200 (OK) status returned and the “Success!” message:

Postman response - Testing validators with FluentValidation

Now that we have everything set up, we are going to add validation to our models and test it out against our API as we go.

Built-in Validators

The built-in validators cover presence, size, format, and set membership, and they all attach to a property through RuleFor().

Presence comes in two strengths. NotNull() rejects only null, while NotEmpty() also rejects an empty string, whitespace, and the type’s default value. That is why NotEmpty() is the one we reach for on strings.

Size covers strings and numbers separately: MinimumLength(), MaximumLength(), and Length() for text, InclusiveBetween() and the comparison validators for numbers.

Format is where the library saves the most work. EmailAddress() and Matches() handle the patterns we would otherwise write and re-write. FluentValidation’s own documentation calls the default email rule “an intentionally naive check to match the behaviour of ASP.NET Core’s EmailAddressAttribute“.

Set membership is IsInEnum(). It matters more than it looks: an enum property bound from JSON accepts any integer, including values the enum never declared, and IsInEnum() is what rejects them.

Anything the library does not cover goes to Must(), which takes a predicate.

Null & Empty Validation

The most common and simple validation is ensuring a value is set, and in the case of strings or sequences, ensuring the value is not empty.

Let’s add the following two rules to our OrderValidator:

RuleFor(model => model.CustomerName).NotNull();
RuleFor(model => model.CustomerEmail).NotEmpty();

These two rules ensure a value is present for CustomerName, and we have a non-whitespace value for CustomerEmail. In most cases, we’d just use the NotEmpty() validator, unless we want to allow whitespace as a valid value, however, it’s worth showing both options.

Both of these rules are unconditional. When a property is required only in some cases, FluentValidation’s When() and Unless() methods do the same job as conditional required attributes, without a custom attribute in sight.

Length Validation

After we’ve ensured our strings contain a non-whitespace value, the next validation we would normally like is around the length.

Let’s amend the rule we just created for CustomerName:

RuleFor(model => model.CustomerName).MinimumLength(10);

Now we are ensuring the name is at least 10 characters long. If we prefer, we can make use of the following similar length validators:

  • MaximumLength (useful if we want to ensure a string is less than or equal to a specified number of characters)
  • Length (when we want the string to be exactly a certain number of characters, or within a range)

Range Validation

Generally, whenever we have integers on our model, we need to apply some kind of reasonable bounds over the value. This is where we can make use of the range validators.

Let’s get back to the OrderValidator class and add a validator against our Price property:

RuleFor(model => model.Price).InclusiveBetween(1, 1000);

Here we have simply ensured the value of the price is between 1-1000 inclusive. We can also use the following similar validators whose usage is thankfully obvious due to the naming:

  • ExclusiveBetween
  • LessThan / LessThanOrEqualTo
  • GreaterThan / GreaterThanOrEqualTo

Email Validation

Another commonly required validation is against email addresses. All we need to do to enable this is to add the following rule against our CustomerEmail property, replacing the NotEmpty() rule we had previously:

RuleFor(model => model.CustomerEmail).EmailAddress();

It is worth being precise about what this validator does, because the name promises more than the rule delivers. The default EmailAddress() check is deliberately naive: it accepts any value containing a single @ that is not at the start or the end. So a@b passes, and so does has [email protected], while not-an-email and a@b@c are rejected. That is intentional, and it matches what ASP.NET Core’s own EmailAddressAttribute does.

If we need something stricter, the Matches() validator takes a regular expression and is the right place to put our own rule.

Enum Validation

Most .NET applications make use of enumerations to signify the state of an object. Often painfully when validating these, we need to do casting/boxing/unboxing and check all the various members of the enum to see if the input is valid.

With FluentValidation it’s extremely easy. To demonstrate that, let’s add enum validation against our OrderStatus property:

RuleFor(model => model.OrderStatus).IsInEnum();

The IsInEnum() method will ensure the value is one of the included members of the enum.

While it may seem simple here, consider the scenario of an API like in the previous article. In that scenario, if we had an enum on our model we would accept the value from the calling application in the form of an int or string, which would then be model-bound to our enum property. Since we have no control over these input values, we need to apply a “whitelist-style” approach to the enum validation to ensure the bound value is what we allow. This is exactly what the IsInEnum() method does for us.

Let’s send the same request in Postman we had previously and see the result:

Postman response - enum validators with FluentValidation

Now, we see we are receiving validation errors for the rules we created, which means everything is working well.

Let’s change the body of the request to make everything succeed again:

Postman response - Successful enum validation with FluentValidation

We’ve now covered most of the built-in validators we’d normally make use of. In the next section, we’ll discuss how to chain validators together.

Chaining Validators

Often we need to apply more than one validation rule to a particular property. That’s where chaining comes in.

Let’s demonstrate this by chaining a validator to our existing EmailAddress() validator:

RuleFor(model => model.CustomerEmail)
    .EmailAddress()
    .MinimumLength(20);

Here we are ensuring two things:

  1. The value of CustomerEmail is a valid email address
  2. The value of CustomerEmail is at least 20 characters

It’s quite a contrived example, but it demonstrates a technique we can apply when we want to make use of a built-in validator, but it doesn’t quite meet our needs and we want to be more specific.

Let’s modify the value of customerEmail in the Postman request to “AAAAA” and hit Send:

Postman response - Chaining validators with FluentValidation

Notice we are now receiving 2 validation errors, for each of our chained rules.

Here it’s also worth mentioning how the “cascade” mode of FluentValidation works. In the above example, the EmailAddress() validator is applied, and regardless if the validation succeeds or fails, the MinimumLength() validation is then also applied.

This has the side effect of applying extra validation (and therefore processing) when the call is going to fail anyway. Sometimes this is not desired.

To prevent this, let’s change the email validation rule:

RuleFor(model => model.CustomerEmail)
    .Cascade(CascadeMode.Stop)
    .EmailAddress()
    .MinimumLength(20);

Now, if the EmailAddress() validation fails, the MinimumLength() validator is not executed. The Cascade method can also be applied to the entire validator in the constructor, or globally to all validators.

The CascadeMode enumeration declares exactly two members today, Continue and Stop. Older code and older articles use CascadeMode.StopOnFirstFailure, which has been removed outright rather than deprecated — on FluentValidation 12 that line is a compile error, not a warning.

Let’s run the app and hit the same request in Postman again:

Postman response - Cascade options for validators with FluentValidation

Now only the first validation rule is displayed. Let’s change the value of customerEmail back to “[email protected]” so our input is valid again.

In the next section, we’ll discuss how we can use nested validators if we have multiple classes.

Nested Validators

In our current project, we have two classes Order and Product. So far, we’ve only applied validation to our Order class. What if we wanted to apply validation to our Product class also? How would we then ensure that validation is called when we validate the Order? This is a common scenario, so let’s see how to implement it.

First, let’s add a simple validator for our Product class:

public class ProductValidator : AbstractValidator<Product>
{
    public ProductValidator()
    {
        RuleFor(model => model.Name).NotEmpty();
    }
}

Let’s keep it simple here because we want to focus on how to call this validator from the Order class.

That said, let’s add a new rule to our Order validator:

RuleFor(model => model.Product)
    .NotNull()
    .SetValidator(new ProductValidator());

Very simply, we are ensuring a value for Product is sent, and handing over validation to the ProductValidator. This is a great example of separation of concerns, as if/when the Product class evolves (and therefore, the validation rules), it’s not a concern of the OrderValidator.

Let’s run our API and execute the existing request in Postman:

Postman response - Erroring nested validators with FluentValidation

Now, we receive an error for the product property, specifying it needs to exist. This is our NotNull() validation rule firing.

Let’s try changing the value of the product to an empty object:

Postman response - Nested validator error with FluentValidation

We are still receiving an error, but notice it’s now the nested validation rule firing for the Product.Name property.

To fix the input, let’s change the input again:

Postman response - Successful nested validation with FluentValidation

We can see the request is now succeeding.

Next, let’s talk about how we can validate collections.

Collections

In our current project, a single Order has a single Product. But what if a single order has many products? (a more realistic scenario).

Let’s update our Order class to reflect that:

public Product[] Products { get; set; }

If we jump over to our OrderValidator, we now see that we have a build error on the following line:

RuleFor(model => model.Product)
    .NotNull()
    .SetValidator(new ProductValidator());

What we’d like to do now is still make use of our ProductValidator, but invoke it for each product.

To do that, we are going to modify the erroring line:

RuleForEach(model => model.Products).SetValidator(new ProductValidator());

Now, we ensure that each product is validated against the ProductValidator rules.

Let’s run the app, and modify our Postman request:

Postman response - Collection validators with FluentValidation

Notice how we have two products in the input, but validation is failing for the second (index 1) product because the “name” property is empty. This proves validation is being executed for each item in the array, and the failure is reported against Products[1].Name so we know exactly which item was wrong.

Let’s fix up the Postman request:

Postman response - Successful collection validation with FluentValidation

Now everything is working again.

In the next section, we’ll talk about how to throw validation exceptions.

Throwing Exceptions

When it comes to invoking our validators, the most common way is by using the Validate() method, and then interrogating the ValidationResult.

However, what if we want to automatically throw an exception and short-circuit the code?

We can do that by using the ValidateAndThrow() method:

orderValidator.ValidateAndThrow(order);

Notice this time we’re not capturing the validation result. This is because ValidateAndThrow returns void. Either the validation succeeds and the code continues, or a ValidationException is thrown.

The most common reason we do this is due to global error handling. Instead of constantly checking for validation results all over our code and acting appropriately (which would violate the “DRY” principle), we can instead throw a ValidationException and catch that exception higher up in our code.

Default Behavior

In our API, we are calling the validator explicitly and inspecting the result, which is the behavior we wired up earlier. Let’s look at what that costs us in a smaller example before we let the exception fly.

To demonstrate the behavior, let’s amend our existing API method:

[HttpPost]
public ActionResult Post([FromBody] Order order)
{
    var product = new Product
    {
        Name = null // will fail validation
    };

    var validationResult = new ProductValidator().Validate(product);

    return validationResult.IsValid
        ? (ActionResult) Ok("Success!")
        : BadRequest("Validation failed");
}

Let’s explain our code:

  1. First, we create a new Product with values we know will fail validation
  2. Next, we create a ProductValidator() and validate the product
  3. Finally, we return Ok or BadRequest depending on the validation result

If we run our API and execute the existing request in Postman:

Postman response - Default validation behavior with FluentValidation

We can see the response “Validation failed”.

Using ValidateAndThrow()

Let’s now change our code to throw instead. First, let’s add a reference to FluentValidation:

using FluentValidation;

Next, let’s modify our method:

[HttpPost]
public ActionResult Post([FromBody] Order order)
{
    var product = new Product
    {
        Name = null // will fail validation
    };

    new ProductValidator().ValidateAndThrow(product);

    return Ok("Success!");
}

Now, if we run our API and execute the request in Postman, the response code is 500 (Internal Server Error) and the body reports that a ValidationException was thrown.

That is not a great payload for a bad request, which is why ValidateAndThrow() only pays off when something further up is waiting to catch the exception and turn it into a response. Global error handling middleware is one such place; running validators inside a MediatR pipeline is another, where a behaviour validates the request and the pipeline never reaches the handler.

Let’s revert our method back to the version we wired up earlier:

[HttpPost]
public async Task<ActionResult> Post([FromBody] Order order)
{
    var validationResult = await _validator.ValidateAsync(order);

    if (!validationResult.IsValid)
    {
        foreach (var error in validationResult.Errors)
        {
            ModelState.AddModelError(error.PropertyName, error.ErrorMessage);
        }

        return ValidationProblem(ModelState);
    }

    return Ok("Success!");
}

In the final section, we’ll talk about how to take FluentValidation to the next level in the form of custom validators.

How Do We Write a Custom Validator in FluentValidation?

There are two ways, and the one to pick depends on whether the rule needs a name.

For a one-off rule, Must() takes a predicate and is done in a line. It receives the property value, returns bool, and pairs with WithMessage() to say what went wrong.

For a rule used in more than one validator, we write an extension method on IRuleBuilder<T, TProperty> that returns IRuleBuilderOptions<T, TProperty>. Inside it we compose existing validators and our own Must() call, then call it like any built-in rule.

The extension-method form is what makes a custom rule reusable, because the rule and its message travel together and every validator gets the same behaviour.

For rules that need injected services or async work, the third option is a class implementing IPropertyValidator or an AbstractValidator passed to SetValidator().

If we right-click on any built-in validator we’ve used so far and select “Go to definition”, we’ll see it lives in a class called DefaultValidatorExtensions. Those validators extend the IRuleBuilder interface, and so will ours.

Let’s take a look at our existing validation for the CustomerName property on our Order class:

RuleFor(model => model.CustomerName).MinimumLength(10);

What if we want to do more than ensure it’s at least 10 characters? What if we want to make sure it’s actually a real name with a first and surname, with a space in the middle? Let’s build a custom validator to encapsulate that logic.

Adding a Custom FullName Validator

First, let’s add a FluentValidationExtensions class with a new extension method:

public static class FluentValidationExtensions
{
    public static IRuleBuilderOptions<T, string> FullName<T>(this IRuleBuilder<T, string> ruleBuilder)
    {
        return ruleBuilder
                   .MinimumLength(10)
                   .Must(val => val.Split(" ").Length >= 2);
    }
}

Let’s explain our code:

  1. Firstly, we can see that there are 2 type parameters on the method signature, T and string. Essentially this means that the custom validator can be applied to any validator of a class, as long as the property being validated is a string. If we want to restrict it to certain classes, we could make use of C# generic constraints, but we keep it simple for demonstration purposes.
  2. In the implementation, we use the existing MinimumLength validator, and add the validation code:
.Must(val => val.Split(" ").Length >= 2);

We’re making use of “chaining validators” (which we discussed previously), and also making use of the Must validator which accepts a predicate requiring a return type of bool. Essentially, this is where we can put any custom logic. In this case, we’re splitting the value by space into an array and ensuring there are at least two elements (first name, then potentially a middle and last names). Of course, this is a very naive implementation, but again we keep it simple to focus on different validators with FluentValidation and not complicated logic.

Using Our Custom Validator

Let’s modify our validation rule to use the new validator:

RuleFor(model => model.CustomerName).FullName();

Now, let’s run our API again and execute the Postman request:

Postman response - Custom validators with FluentValidation

Our request still succeeds.

Let’s see what happens if we change the value of customerName to “JoeBloggs” (no space) and execute the request again:

Postman response - Failing custom validators with FluentValidation

Notice both our validation rules failed. Also, we can see the second validation rule isn’t very user friendly, so let’s amend the custom validator and make use of the WithMessage() method:

return ruleBuilder
           .MinimumLength(10)
           .Must(val => val.Split(" ").Length == 2)
           .WithMessage("Name must contain a single space and be at least 10 characters long");

This should make the error a bit easier to understand. Let’s run our API and execute the request again:

Postman response - Custom error messages in validators with FluentValidation

Now the client should be able to easily understand why their request failed, and make the necessary adjustments. The WithMessage() method is useful for this purpose when we want to provide better error information than FluentValidation provides by default, which is especially important when building a public API.

Which FluentValidation Validator Do We Need?

We have now used every validator this article covers at least once. Rather than scrolling back through the walkthrough to find the one we need, here is the whole set in one place, along with what each one actually checks:

We want to checkValidatorNote
A value is presentNotNull()Rejects null; allows empty and whitespace strings
A value is present and meaningfulNotEmpty()Rejects null, empty string, whitespace, and default values
A string's lengthMinimumLength(), MaximumLength(), Length()Length(min, max) takes a range
A number's boundsInclusiveBetween(), ExclusiveBetween()Also GreaterThan(), LessThanOrEqualTo() and their variants
An email addressEmailAddress()Checks only that the value contains a single @, not that it is well formed: a@b passes and so does has [email protected]
A value against a patternMatches()Takes a regex or a Regex instance
An enum value is a declared memberIsInEnum()Catches out-of-range ints bound from a request
A value is one of a setIsInEnum(), or Must() with a lookupNo dedicated "one of" validator
Anything elseMust()Takes a predicate returning bool
A nested objectSetValidator()Hands the property to another AbstractValidator<T>
Every item in a collectionRuleForEach().SetValidator()Errors are reported with the item index
To stop after the first failure.Cascade(CascadeMode.Stop)The older StopOnFirstFailure name is removed, not deprecated — it is a hard CS0117 on 12.1.1
To replace the message.WithMessage()Also .WithErrorCode() and .WithSeverity()

Two rows in that table are worth reading twice. NotNull() and NotEmpty() are not interchangeable, and EmailAddress() is far more permissive than its name suggests.

Conclusion

In this article, we went down the rabbit hole and covered many scenarios we’d usually need to deal with when validating objects in a .NET application: the built-in validators for presence, length, range, format and enums, chaining them with a cascade mode, nesting validators, validating collections, throwing on failure, and finally writing a custom validator of our own.

For most companies, our data is our most valuable asset so it’s important we protect what goes into our system. That’s why it’s essential to be equipped with a great library like FluentValidation and know which rules to apply to what scenarios.

Happy validating and remember.. “never trust user input!”.

Tested with .NET 10.0.10 and FluentValidation 12.1.1.