Updated on
To use FluentValidation in ASP.NET Core, we write our rules in a validator class, register it with dependency injection, and call it from the controller before we do anything with the request.
Traditionally, most validation in .NET is done using Data Annotations:
public class SampleClass
{
[Required]
public int Id { get; set; }
[MaxLength(100)]
public string? Name { get; set; }
}
There are a few issues with this approach:
- Our model can get “bloated”
- Extensibility is limited
- Testing isn’t the nicest experience
To address some of these concerns, instead, we’re going to utilize a .NET library called FluentValidation to perform validation for our classes. We’re going to add validation to a basic ASP.NET Core API, but the techniques we’re going to follow can in fact be applied to any .NET application.
Let’s move on.
How Does FluentValidation Work in ASP.NET Core?
Using FluentValidation in ASP.NET Core takes three pieces: a validator that holds the rules, a registration that makes the validator injectable, and a call that runs it before our own code does.
A validator is a class that inherits from AbstractValidator<T> and declares its rules in the constructor, one RuleFor() chain per property. It doesn’t depend on ASP.NET Core, so it can live in any project, next to the model or in a separate class library.
AddValidatorsFromAssemblyContaining<T>(), from the FluentValidation.DependencyInjectionExtensions package, finds the validators in an assembly and registers each one as IValidator<T>. From then on, a controller, a use case or any other class can ask for a validator like any other service.
The call is up to us. We run ValidateAsync(), and if the result isn’t valid, we send every error back to the client in a 400 response. The automatic validation from the old FluentValidation.AspNetCore package is deprecated, so nothing calls the validator for us.
Creating a Simple ASP.NET Core API
Let’s go ahead and create a File -> New -> ASP.NET Core Web API project using Visual Studio, targeting .NET 10 with “Use controllers” checked, accepting all the other defaults. From the command line, this does the same:
dotnet new webapi --use-controllers -n WebApplication1
Either way, we’ll end up with the familiar “Weather Forecast” API structure:
WebApplication1/
Controllers/
WeatherForecastController.cs
Properties/
launchSettings.json
appsettings.Development.json
appsettings.json
Program.cs
WeatherForecast.cs
WebApplication1.csproj
WebApplication1.http
Depending on our SDK version, the template might reference an older Microsoft.AspNetCore.OpenApi package. On SDK 10.0.302, it’s version 10.0.10, which pulls in Microsoft.OpenApi 2.0.0, and we get this warning as soon as the packages restore:
warning NU1903: Package 'Microsoft.OpenApi' 2.0.0 has a known high severity vulnerability, https://github.com/advisories/GHSA-v5pm-xwqc-g5wc
If we see it, let’s update the package to the latest 10.0 patch:
dotnet add WebApplication1 package Microsoft.AspNetCore.OpenApi --version 10.0.12
This version brings in a fixed Microsoft.OpenApi, and the warning is gone.
Now let’s hit CTRL-F5 (or run dotnet run --project WebApplication1) to run our API, send the GET request from the template’s WebApplication1.http file, and we should see some weather readings for the next few days:
[
{
"date": "2026-09-28",
"temperatureC": 30,
"temperatureF": 85,
"summary": "Bracing"
},
{
"date": "2026-09-29",
"temperatureC": -20,
"temperatureF": -3,
"summary": "Chilly"
},
{
"date": "2026-09-30",
"temperatureC": 27,
"temperatureF": 80,
"summary": "Scorching"
},
{
"date": "2026-10-01",
"temperatureC": 22,
"temperatureF": 71,
"summary": "Bracing"
},
{
"date": "2026-10-02",
"temperatureC": 54,
"temperatureF": 129,
"summary": "Freezing"
}
]
To demonstrate the use of validation, we’re going to add a new API method that allows us to add a new forecast.
First, let’s open up Controllers/WeatherForecastController.cs.
We can see there’s a single method called Get() which returns some weather forecasts (an array of WeatherForecast), which is the API called when we ran the application previously:
[HttpGet(Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> Get()
{
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}
Now let’s add a method to the WeatherForecastController class:
[HttpPost]
public ActionResult Post([FromBody] WeatherForecast forecast)
{
return Ok("Success!");
}
This method accepts a WeatherForecast parameter from the body of an HTTP POST request, and returns the string “Success!”.
Normally we’d do a few more useful things such as saving to a database and returning an HTTP 201 (Created) pointing to the location of our newly saved resource. However, for demonstration purposes let’s keep it nice and simple.
Using Postman for API Testing
Let’s now fire up Postman and confirm that our API works as expected.
First, we’ll add a new request in Postman.
In the request, we’ll set the following values:
- “POST” as the HTTP Verb
- http://localhost:5219/weatherforecast as the URL (change the port as necessary)
- A header called “Content-Type” with the value “application/json”
- A body of type “JSON”, setting the “TemperatureC” field on our WeatherForecast model to the value 6000:
{ "temperatureC": 6000 }
Then, let’s hit the “Send” button. If we did everything correctly, we should see our “Success!” text in the response.
Great!
So we now have the ability to add a new weather forecast. Notice however we’ve passed a value of 6000 as the value for TemperatureC. This is hotter than the surface of the Sun, so we probably shouldn’t allow this into our system for a weather forecast!
To put some rules around this, in the next section let’s add a simple validator to ensure that the value of TemperatureC cannot exceed 100.
Adding a Simple FluentValidation Validator
To add our simple validator, we first need to install FluentValidation:
dotnet add WebApplication1 package FluentValidation.DependencyInjectionExtensions --version 12.1.1
The FluentValidation.DependencyInjectionExtensions package installs both FluentValidation and also the methods that register our validators with ASP.NET Core’s dependency injection, which we’ll make use of a bit later. Both are free and open source under the Apache 2.0 license.
If you’ve used FluentValidation before, you might expect FluentValidation.AspNetCore here. That package is deprecated, and we’ll see why once we wire up our validator.
Now, let’s go ahead and add a new validator with our rule directly in the WeatherForecast.cs file, next to the WeatherForecast class:
using FluentValidation;
namespace WebApplication1;
public class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
public string? Summary { get; set; }
}
public class WeatherForecastValidator : AbstractValidator<WeatherForecast>
{
public WeatherForecastValidator()
{
RuleFor(model => model.TemperatureC).LessThanOrEqualTo(100);
}
}
Let’s explain our code:
- We create a class called
WeatherForecastValidatorthat inherits from theAbstractValidator<T>class, specifying the typeWeatherForecast. This lets FluentValidation know that this validation is for theWeatherForecastclass. - We can see a constructor specifying our rules. In this case, we define a single rule saying that the
TemperatureCvalue needs to be <=100.
We can place the validation class anywhere we like, but for the sake of simplicity let’s keep it in the same file.
We can add as many rules as we like, chain validators, and even use custom validators, but we’ll focus on a single simple rule for now.
In the next section, we’ll look at how we can write a simple unit test for our validator.
Testing Our FluentValidation Validator
One of the great things about FluentValidation is how easy it is to write unit tests. There is a nice set of built-in test helpers that make assertions a breeze and keep our tests nice and clean. To learn more about testing ASP.NET Core application, we strongly recommend reading our ASP.NET Core Testing series.
Let’s go ahead and set one up now.
First, we’ll add a new xUnit Test Project to our solution:
dotnet new xunit -n WebApplication1.Tests
Let’s rename UnitTest1.cs to WeatherForecastValidatorTests.cs, and the class inside it to WeatherForecastValidatorTests. It’s good practice to name the test file matching the validator we are testing.
Next, let’s add a reference to our WebApplication1 project:
dotnet add WebApplication1.Tests reference WebApplication1
Now we are going to install the FluentValidation library into the tests project:
dotnet add WebApplication1.Tests package FluentValidation --version 12.1.1
The xUnit Visual Studio test runner already comes with the template, so there’s nothing else to install.
Then let’s go ahead and open up the WeatherForecastValidatorTests class and add some tests.
Adding Test Methods
First, we’ll add the following using directives:
using FluentValidation.TestHelper; using WebApplication1;
The first directive imports a set of test helpers that we can utilize, and the second imports the namespace of our web application, where the validator we wrote earlier lives.
Next, let’s remove the empty Test1() method that came with the template, and add the following instance member to the WeatherForecastValidatorTests class:
private readonly WeatherForecastValidator _validator = new WeatherForecastValidator();
This creates an instance of our validator so that we can use it for the tests we are about to write.
Now, we are going to add a method to test failing validation:
[Fact]
public void GivenAnInvalidTemperatureCValue_ShouldHaveValidationError()
{
var forecast = new WeatherForecast { TemperatureC = 101 };
var result = _validator.TestValidate(forecast);
result.ShouldHaveValidationErrorFor(model => model.TemperatureC);
}
We are using the TestValidate() extension from the FluentValidation.TestHelper namespace, that allows us to do 3 things in 3 short lines:
- Set a property to a value of our choosing (in this case,
101) - Invoke the validator
- Cause the test to pass/fail based on the result
Do your older tests call ShouldHaveValidationErrorFor() on the validator itself, with the value as the second argument? Then they won’t compile any more, because FluentValidation 11 removed those helpers in favor of TestValidate().
After that, let’s add a method to test successful validation:
[Theory]
[InlineData(99)]
[InlineData(100)]
public void GivenAValidTemperatureCValue_ShouldNotHaveValidationError(int temperatureC)
{
var forecast = new WeatherForecast { TemperatureC = temperatureC };
var result = _validator.TestValidate(forecast);
result.ShouldNotHaveValidationErrorFor(model => model.TemperatureC);
}
This time we’re using the handy xUnit “theories”, which allow us to pass multiple values to the test.
Now, if we jump over to the Test Explorer and run all our tests, we should see green lights across the board. From the command line, dotnet test tells us the same:
dotnet test WebApplication1.Tests
At the end of the output, we’ll see:
Test summary: total: 3, failed: 0, succeeded: 3, skipped: 0, duration: 1.0s Build succeeded in 2.6s
As you can see, FluentValidation makes it really easy to test our validation, allowing us to focus on testing individual properties. However, we can still test the entire validators for more complex scenarios.
Now that we know our validator works as expected, in the next section we are going to wire it up in our API.
Wiring up Our FluentValidation Validator
Firstly, we need to tell ASP.NET Core that we’d like to use FluentValidation and to look for validators in our assembly.
To do that, we need to add a single line to the Program class, plus two using directives at the top:
using FluentValidation;
using WebApplication1;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllers();
builder.Services.AddValidatorsFromAssemblyContaining<WeatherForecastValidator>();
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
This tells FluentValidation to look for any validators in the assembly containing our WeatherForecastValidator, and registers each one with dependency injection as IValidator<T>. In other words, look for any validators in our API project.
By default, the validators are registered as scoped. If you need a refresher on what that means, check out our article on ASP.NET Core dependency injection.
So, does our validator run now? Not yet. This line only registers it, so let’s open WeatherForecastController.cs again, add using FluentValidation; at the top, and call the validator from our Post() method:
[HttpPost]
public async Task<ActionResult> Post([FromBody] WeatherForecast forecast, [FromServices] IValidator<WeatherForecast> validator)
{
var result = await validator.ValidateAsync(forecast);
if (!result.IsValid)
{
foreach (var error in result.Errors)
{
ModelState.AddModelError(error.PropertyName, error.ErrorMessage);
}
return ValidationProblem(ModelState);
}
return Ok("Success!");
}
Here, we ask for IValidator<WeatherForecast> like any other service and call ValidateAsync().
If the forecast isn’t valid, we copy every error into the ModelState and return ValidationProblem(ModelState), which sends the same 400 response ASP.NET Core sends for Data Annotations errors. Our article on ModelState validation explains how that works.
Let’s restart the app and confirm everything is working by hitting Send again in Postman:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"TemperatureC": [
"'Temperature C' must be less than or equal to '100'."
]
},
"traceId": "00-8233e5ab7064d4a9ee8149cdafc728a5-c549fb0927ac4761-00"
}
As expected, our validation is firing correctly, and we’re now returning an error back to the user.
What Happened to FluentValidation.AspNetCore?
If you’ve wired up FluentValidation before, maybe with the 2020 version of this article, your registration probably looks like this. This is the registration we’re replacing, so read it, don’t copy it:
builder.Services.AddControllers()
.AddFluentValidation(fv => fv.RegisterValidatorsFromAssemblyContaining<WeatherForecastValidator>());
With it, we didn’t need to explicitly check the ModelState in our controller. The FluentValidation ASP.NET Core integration would automatically find our validator, and if validation failed, it would prepare the ModelState and the request would get a 400 response without reaching our action.
Does that line still work? On .NET 10 it still compiles, but with two obsolete warnings. One of them reads:
warning CS0618: 'FluentValidationMvcExtensions.AddFluentValidation(IMvcBuilder, Action<FluentValidationMvcConfiguration>)' is obsolete: 'Calling AddFluentValidation() is deprecated. Call services.AddFluentValidationAutoValidation().AddFluentValidationClientsideAdapters() instead, which has the same effect. For details see https://github.com/FluentValidation/FluentValidation/issues/1965'
The warning points at AddFluentValidationAutoValidation(), but the whole package is on its way out. Its README says: “The FluentValidation.AspNetCore package is no longer being maintained and is now unsupported.”
NuGet marks its last version, 11.3.1, as deprecated for being legacy.
The automatic validation had two more limits.
It lives inside MVC, so minimal API endpoints never get it. And MVC’s validation pipeline is synchronous, so a validator with an async rule, like MustAsync(), fails the request with a 500.
So what should we do about it? Remove FluentValidation.AspNetCore, add FluentValidation.DependencyInjectionExtensions, register the validators with AddValidatorsFromAssemblyContaining<T>(), and call them ourselves, like our Post() method does.
FluentValidation’s ASP.NET Core docs call this manual validation “the most straightforward approach and also the easiest to see what’s happening.”
Want automatic validation back? The FluentValidation docs point to a third-party package, SharpGrip.FluentValidation.AutoValidation, which validates from a filter that can run async rules. We like seeing the call in the action, but that’s a matter of taste.
Validating in the Application Layer
Our controller calls the validator now, and that’s fine while HTTP is the only way in. But what if the same forecast arrives from somewhere else?
In bigger apps, the same operation often has other callers, like a background job that imports forecasts from a partner, or a test.
Only one of those callers passes through our controller:

That’s why you’ll often see input validation move into the Application layer, the part of the solution that holds the use cases.
When a use case validates its own input, every caller gets the same checks.
Let’s see how that looks for our forecasts. Create WebApplication1/AddWeatherForecastHandler.cs:
using FluentValidation;
namespace WebApplication1;
public class AddWeatherForecastHandler(IValidator<WeatherForecast> validator)
{
public async Task HandleAsync(WeatherForecast forecast, CancellationToken cancellationToken = default)
{
await validator.ValidateAndThrowAsync(forecast, cancellationToken);
// A real use case would save the forecast here.
}
}
The handler’s first line calls ValidateAndThrowAsync(). It runs the validator and, if the forecast isn’t valid, throws a ValidationException that carries every error. For a bad forecast, nothing after that line runs.
In a layered solution, the handler and the validator live in the Application project. FluentValidation doesn’t depend on ASP.NET Core, so that project stays free of anything HTTP. To keep our example small, we’ll leave both in the API project.
Does your app already use MediatR? Then a pipeline behavior can run the validators before every handler, and our article on the validation pipeline with MediatR shows that setup.
Keep in mind that MediatR 13.0.0 and later are dual-licensed, under the Reciprocal Public License 1.5 or a commercial license. The last Apache 2.0 release is 12.5.0.
The handler throws, so the API needs one place that turns the exception into a 400. Create WebApplication1/ValidationExceptionHandler.cs:
using FluentValidation;
using FluentValidation.Results;
using Microsoft.AspNetCore.Diagnostics;
namespace WebApplication1;
public class ValidationExceptionHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
if (exception is not ValidationException validationException)
return false;
var errors = new ValidationResult(validationException.Errors).ToDictionary();
await Results.ValidationProblem(errors).ExecuteAsync(httpContext);
return true;
}
}
Here, TryHandleAsync() returns false for any other exception, so a real bug still ends up as a 500. For a ValidationException, it turns the errors into a dictionary and writes Results.ValidationProblem() to the response. IExceptionHandler has more to it than this, and our article on global exception handling covers the rest.
Now let’s register both in the Program class:
using FluentValidation;
using WebApplication1;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllers();
builder.Services.AddValidatorsFromAssemblyContaining<WeatherForecastValidator>();
builder.Services.AddScoped<AddWeatherForecastHandler>();
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<ValidationExceptionHandler>();
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
var app = builder.Build();
app.UseExceptionHandler();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
AddProblemDetails() gives our error responses the problem details format, and UseExceptionHandler() sends unhandled exceptions to our ValidationExceptionHandler.
Finally, our Post() method only has to call the handler:
[HttpPost]
public async Task<ActionResult> Post([FromBody] WeatherForecast forecast, [FromServices] AddWeatherForecastHandler handler)
{
await handler.HandleAsync(forecast);
return Ok("Success!");
}
When we restart the app and hit Send in Postman again, we get the same 400 response as before, apart from the trace ID. This time it comes from the exception handler.
If your use cases return a Result type for expected failures, throwing here can look like a contradiction. We make input validation the one exception, because a failed validation carries a list of errors, and one exception handler gives every use case the same 400. Our Result pattern in .NET article shows the other side of that choice.
The last caller is a test. It calls the handler directly, with no HTTP request anywhere, the way a background job would. Create WebApplication1.Tests/AddWeatherForecastHandlerTests.cs:
using FluentValidation;
using WebApplication1;
namespace WebApplication1.Tests;
public class AddWeatherForecastHandlerTests
{
[Fact]
public async Task GivenAnInvalidForecast_HandlerShouldThrowBeforeSaving()
{
var handler = new AddWeatherForecastHandler(new WeatherForecastValidator());
var forecast = new WeatherForecast { TemperatureC = 6000 };
await Assert.ThrowsAsync<ValidationException>(() => handler.HandleAsync(forecast));
}
}
Let’s run our tests again:
dotnet test WebApplication1.Tests
This time, four of them pass:
Test summary: total: 4, failed: 0, succeeded: 4, skipped: 0, duration: 0.9s Build succeeded in 2.5s
Conclusion
FluentValidation provides a great alternative to Data Annotations in order to validate our models. As we’ve seen, the validation rules are easy to read, easy to test, and enable great separation of concerns keeping our controllers lightweight. And when other callers need the same checks, the same validator runs inside a use case without a single change.
In this article, we’ve only really touched the tip of the iceberg. FluentValidation also has a bunch of other great features such as:
- Collection validators, where we can invoke our validator N times when we have a sequence of items
- A large number of built-in validators (for example, validating credit card numbers, email addresses, and enums)
- Ability to write custom validators and pull in dependencies, for example, if we need to do validation via our database
If you’d like to try them out, our article on different validators with FluentValidation is a good next step.
Happy coding!
Tested with .NET 10.0.12, FluentValidation 12.1.1 and xUnit 2.9.3.

If you are getting green squiggles when you try to wire fluid validation, the method given in this example has been depreciated. The new methods are here, https://github.com/fluentvalidation/fluentvalidation/issues/1965 .
Thanks for the tip Courtland! Hope you enjoyed the article 🙂
I did. Well written.
I love FluentValidation but just to be clear, it is not returning the JSON error response itself. The FluentValidation library just sets ModelState and it is ASP.NET Web Api that creates the standard ProblemDetails response. This is because the controller is decorated with [ApiController] and it would return the same response when using standard data annotations. See the docs for more info.
Hi Paul. Yes, that is completely true, We didn’t mean literally the FluentValidation returns a response, but now when I read the sentence, I can understand why you understood that way. Thanks for the suggestion, it is going to be fixed.
thanks for sharing. We have to use
If(!ModelState.IsValid)
{
return BadRequest(ModelState); // return validation error message in Postman
}