Mert Özen Further With Every Line
Backend .NET Core Web API — #09

Model Validation in ASP.NET Core: Data Annotations and FluentValidation

Mert Özen Nov 4, 2026 8 min 2 views
Model Validation in ASP.NET Core: Data Annotations and FluentValidation

Model validation with Data Annotations, ModelState and the automatic 400, customizing ValidationProblemDetails, custom attributes, IValidatableObject and FluentValidation 11.x, including culture pitfalls with Range and messages.

Version note: The code in this post is written for .NET 6 and runs unchanged on .NET 10.

Guess First

Picture a request class without a single attribute. Nullable reference types are enabled in the project:

namespace ProductCatalog.Models;

public class ImplicitRequest
{
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
}

We send two requests to an action that reads this class from the body: one with {"price":100}, the other with {"name":null,"price":100}. We never wrote [Required]. Do you think both return 200, both return 400, or one returns 200 and the other 400?

Rules with Data Annotations

In the previous post we saw how data reaches an action. Model validation checks whether that data follows the rules. The most familiar approach in .NET is Data Annotations: you write the rules as attributes on top of the properties.

using System.ComponentModel.DataAnnotations;
using ProductCatalog.Validation;

namespace ProductCatalog.Models;

public class CreateProductRequest
{
    [Required]
    [StringLength(100, MinimumLength = 2)]
    public string Name { get; set; } = string.Empty;

    [Required]
    [RegularExpression(@"^[A-Z]{3}-\d{4}$", ErrorMessage = "Sku must look like ABC-1234.")]
    public string Sku { get; set; } = string.Empty;

    [AllowedCategory("peripherals", "books", "office")]
    public string Category { get; set; } = string.Empty;

    [Range(typeof(decimal), "0.01", "100000", ParseLimitsInInvariantCulture = true)]
    public decimal Price { get; set; }

    public decimal? DiscountPrice { get; set; }
}

[Required] demands that the value is present, [StringLength] a length range, [RegularExpression] a match against a pattern, and [Range] a number within bounds. ErrorMessage lets you replace the default message. [AllowedCategory] is an attribute of our own; we will get to it shortly. As for why ParseLimitsInInvariantCulture is there, you will see in the pitfalls section; it is an important detail if your servers do not run with an English locale.

Let's send a bad request: {"name":"K","sku":"kb-1","category":"toys","price":0}

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8

{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.1","title":"One or more validation errors occurred.","status":400,"traceId":"00-6c530b4549e2e0aaaff5a62810db3544-7c2e534ad5c03d30-00","errors":{"Sku":["Sku must look like ABC-1234."],"Name":["The field Name must be a string with a minimum length of 2 and a maximum length of 100."],"Price":["The field Price must be between 0.01 and 100000."],"Category":["Category must be one of: peripherals, books, office."]}}

Four fields, four errors, one response. The client can show each error under its own form field instead of stopping at the first one and sending the user back four times. Send {} and Name gets two messages at once: both Required and StringLength reject an empty string.

Now the answer to the opening question. The {"price":100} request returned 200 OK with {"name":"","price":100}. {"name":null,"price":100} got a 400 with "The Name field is required." When nullable reference types are enabled, the framework adds a hidden [Required] to non-nullable properties, but that rule only checks for null. When the property is not sent at all, the string.Empty from the initializer stays in place and the rule passes. An explicit [Required], on the other hand, rejects empty strings too. Always mark required fields explicitly; don't rely on an = string.Empty written to silence a compiler warning.

ModelState and the Automatic 400

When model binding and validation run, every error found is written to ModelState. [ApiController] adds a filter that checks ModelState.IsValid right before the action runs; if it is invalid, it returns the ValidationProblemDetails above without ever calling the action. In a controller without [ApiController], you do this check by hand:

[Route("api/manual-products")]
public class ManualProductsController : ControllerBase
{
    [HttpPost]
    public IActionResult Create([FromBody] CreateProductRequest request)
    {
        // No automatic 400, the check is on us
        if (!ModelState.IsValid)
        {
            return ValidationProblem(ModelState);
        }

        return Ok(request);
    }
}

ValidationProblem(ModelState) produces a 400 in the same format. Validation runs in every case; all [ApiController] does is look at the result and decide for you. Forget this check and invalid data slips in silently.

Customizing ValidationProblemDetails

Some teams use 422 Unprocessable Entity instead of 400 for validation errors, or want their own title. You can replace the factory that produces the automatic response:

builder.Services.AddControllers()
    .ConfigureApiBehaviorOptions(options =>
    {
        options.InvalidModelStateResponseFactory = context =>
        {
            ProblemDetailsFactory factory = context.HttpContext.RequestServices
                .GetRequiredService<ProblemDetailsFactory>();
            ValidationProblemDetails problem = factory.CreateValidationProblemDetails(
                context.HttpContext,
                context.ModelState,
                statusCode: StatusCodes.Status422UnprocessableEntity,
                title: "Request validation failed");
            return new UnprocessableEntityObjectResult(problem);
        };
    });
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json; charset=utf-8

{"type":"https://tools.ietf.org/html/rfc4918#section-11.2","title":"Request validation failed","status":422,"traceId":"00-348060f76cd69ba66261f55cbd52ee63-e152ed1a983528e2-00","errors":{"Name":["The field Name must be a string with a minimum length of 2 and a maximum length of 100."]}}

You could create the ValidationProblemDetails yourself with new, but then the type and traceId fields disappear. Going through ProblemDetailsFactory keeps the ClientErrorMapping we discussed in the previous post and the trace information. Asking RequestServices for the factory is a service locator call; it is acceptable here because it is the only door the framework gives us, but you should not spread this pattern through your own code. We will talk about why in the dependency injection post.

Writing Your Own Attribute

When the built-in attributes are not enough, you derive from ValidationAttribute and write your own:

using System.ComponentModel.DataAnnotations;

namespace ProductCatalog.Validation;

public class AllowedCategoryAttribute : ValidationAttribute
{
    private readonly string[] _categories;

    public AllowedCategoryAttribute(params string[] categories)
    {
        _categories = categories;
    }

    protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
    {
        if (value is string category && _categories.Contains(category))
        {
            return ValidationResult.Success;
        }

        return new ValidationResult(
            $"{validationContext.DisplayName} must be one of: {string.Join(", ", _categories)}.",
            new[] { validationContext.MemberName! });
    }
}

IsValid receives the value and returns ValidationResult.Success if it is valid, or a ValidationResult with a message if not. The member name in the second parameter decides under which key the error lands in the errors dictionary. The message "Category must be one of: peripherals, books, office." in the output above comes from here. Something similar to this attribute was added to the framework in .NET 8 as [AllowedValues]; we will see it in the version posts later on.

IValidatableObject: Rules Across Fields

Attributes look at a single property. For a rule that compares two fields, such as "the discount price must be lower than the regular price", the class itself steps in:

public class CreateProductRequest : IValidatableObject
{
    // Properties are the same as above

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        if (DiscountPrice is not null && DiscountPrice >= Price)
        {
            yield return new ValidationResult(
                "Discount price must be lower than price.",
                new[] { nameof(DiscountPrice) });
        }
    }
}

Send an otherwise valid product with a price of 1450 and a discount price of 1500, and the response carries the error "DiscountPrice":["Discount price must be lower than price."]. The interesting part is what happens when you also put a one-letter name into the same request: the response shows only the Name error and the discount error vanishes. Validate does not run until the property-level rules pass. That is a sensible order, since there is no point comparing fields that are broken themselves. But the client learns about the errors in two rounds, so design with that in mind.

Separating Rules with FluentValidation

As rules grow, attributes start to smother the class. Checking list items one by one, making rules conditional, or running async checks is tedious with attributes. FluentValidation moves the rules into a separate class. For a project targeting .NET 6, the series to use is 11.x; the latest one right now is 11.12.0. The 12.x series targets only .NET 8 and above.

dotnet add package FluentValidation --version 11.12.0
dotnet add package FluentValidation.DependencyInjectionExtensions --version 11.12.0

Let's write a validator for the create order request:

using FluentValidation;
using ProductCatalog.Models;

namespace ProductCatalog.Validation;

public class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
    public CreateOrderRequestValidator()
    {
        RuleFor(x => x.CustomerEmail).NotEmpty().EmailAddress();
        RuleFor(x => x.Items).NotEmpty().WithMessage("An order must contain at least one item.");
        RuleForEach(x => x.Items).ChildRules(item =>
        {
            item.RuleFor(i => i.ProductId).GreaterThan(0);
            item.RuleFor(i => i.Quantity).InclusiveBetween(1, 100);
        });
    }
}

CreateOrderRequest is a plain class carrying CustomerEmail and List<OrderItemRequest> Items; it has no attributes at all. We register the validators in Program.cs with a single line: builder.Services.AddValidatorsFromAssemblyContaining<CreateOrderRequestValidator>();. The action side looks like this:

[HttpPost]
public async Task<IActionResult> Create(
    CreateOrderRequest request,
    [FromServices] IValidator<CreateOrderRequest> validator)
{
    ValidationResult result = await validator.ValidateAsync(request);
    if (!result.IsValid)
    {
        foreach (ValidationFailure error in result.Errors)
        {
            ModelState.AddModelError(error.PropertyName, error.ErrorMessage);
        }

        return ValidationProblem(ModelState);
    }

    return Ok(new { message = "Order accepted", itemCount = request.Items.Count });
}

Because we copy the errors into ModelState and return ValidationProblem, the response follows the same schema as the one Data Annotations produced:

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8

{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.1","title":"One or more validation errors occurred.","status":400,"traceId":"00-f8ef95fbe5944a22c9beb009156d21cb-a5d31b7c385d3994-00","errors":{"CustomerEmail":["'Customer Email' is not a valid email address."],"Items[0].Quantity":["'Quantity' must be between 1 and 100. You entered 0."],"Items[1].ProductId":["'Product Id' must be greater than '0'."]}}

Notice the Items[0].Quantity key; it tells the client exactly which list item has the error. Let me also explain why we call the validator by hand. The FluentValidation.AspNetCore package used to plug validators into the MVC pipeline and run them automatically. That package is no longer maintained, it is marked "legacy" on NuGet, and its author recommends calling validators manually. Manual calls have a nice side effect too: you can see when validation runs just by reading the code. We will go into async actions in their own post.

Common Pitfalls

The first pitfall blows up on a machine with a non-English locale. A [Range(typeof(decimal), "0.01", "100000")] written without ParseLimitsInInvariantCulture returns 500 for every request carrying that property on a server whose operating system language is, say, Turkish. The log line is this:

System.ArgumentException: 0.01 is not a valid value for Decimal. (Parameter 'value')

When you give the limits as strings, the attribute parses them with the current culture by default, and in Turkish the decimal separator is a comma. If your development machine is English and the server is Turkish, you only see the bug in production. ParseLimitsInInvariantCulture = true reads the limits the same way on every machine. The message itself is still formatted by culture; on a Turkish server it reads between 0,01 and 100000.

The second one is about culture too. FluentValidation picks its built-in messages based on the operating system language. The same request returns "'Customer Email' geçerli bir e-posta adresi değil." on a Turkish machine and the message above on an English one. An API's language should not depend on server settings; pin it in Program.cs with ValidatorOptions.Global.LanguageManager.Culture = new CultureInfo("en");.

Third, the requirement hidden by = string.Empty. You saw it in the guessing section; without an explicit [Required], records with empty names get in.

Fourth, mixing two systems on the same request. If a class has both Data Annotations and FluentValidation rules, the automatic 400 fires as soon as one of the attribute rules fails, and the action, and therefore FluentValidation, never runs. The client sees half of the rules. Pick a single approach per request.

A Small Experiment

★☆☆ Add a Stock property limited with [Range(0, 1000)] to CreateProductRequest, send -5 and read the message.

★★☆ Write an attribute called [NotBlank] that rejects null, an empty string and a string made only of spaces. Apply it to Name and try it by sending " ".

★★★ Add a rule to CreateOrderRequestValidator that prevents the same ProductId from appearing twice in the list. Then run the app with LC_ALL=tr_TR.UTF-8 dotnet run, watch the language of the messages, and pin it to English with the LanguageManager setting.

In this post we saw the controller side of validation, and [ApiController] did the work for us every time. Minimal APIs, which arrived with .NET 6, have neither [ApiController] nor automatic validation. In the next post we write minimal APIs with MapGet and MapPost, build the same ProductCatalog endpoints without controllers, and talk about when to pick which.