ASP.NET Core Web API · Building APIs

Model Validation

Every byte a client sends might be wrong. ASP.NET Core APIs validate input before your action runs and return a standard 400 response that lists every problem per field — which the React form can show right next to the inputs.

Beginner 7 min Lesson 9 of 37Examples target .NET 9.
0/37 in ASP.NET Core Web API
On this page · 7 min · Beginner

In simple words#

Validation is the security scanner at the counter: wrong or missing fields get an immediate 400 with a clear list of problems.

Data annotations
[Required], [Range] on DTOs.
Automatic 400
[ApiController] returns ValidationProblem.
ProblemDetails
Standard JSON error format.

Learning objectives#

  • Add validation attributes to request DTOs
  • Read the automatic 400 ValidationProblemDetails response
  • Write cross-field rules with IValidatableObject
  • Add business-rule errors from code

Explanation#

Put data annotations on input DTOs: [Required], [StringLength], [Range], [EmailAddress], [RegularExpression], [Url]. With [ApiController], invalid models are rejected automatically — your action never runs.

The response is 400 with application/problem+json: { "title": "One or more validation errors occurred.", "status": 400, "errors": { "Price": ["…"] } }. Field names are keys, so clients can map messages to inputs.

Rules involving several properties go in IValidatableObject.Validate (for example: SalePrice must be lower than Price). They run after the attribute checks pass.

Rules that need data (unique SKU, existing category) belong in the action or service: ModelState.AddModelError("Sku", "…"); return ValidationProblem(ModelState); — the same response shape.

For complex validation many teams use FluentValidation. You can also customise the automatic response with ApiBehaviorOptions.InvalidModelStateResponseFactory.

Syntax#

C# Illustrative
public record CreateProductDto(
    [Required, StringLength(100)] string Name,
    [Range(1, 1_000_000)] decimal Price,
    [RegularExpression("^[A-Z]{2,5}-\\d{1,4}
quot;
)] string Sku);

Code example: Attribute, cross-field and data-dependent validation#

C#Contracts/CreateProductDto.cs Illustrative
// Program.cs
using System.Net.Http.Json;
using System.Text.Json;


var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();                                   // keep the demo output clean
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Urls.Add("http://127.0.0.1:5209");
await app.StartAsync();


using var http = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:5209") };
async Task Post(string label, object body)
{
    var response = await http.PostAsJsonAsync("/api/products", body);
    var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
    Console.WriteLine(
quot;
{label}: {(int)response.StatusCode}");
if (json.TryGetProperty("errors", out var errors)) foreach (var field in errors.EnumerateObject()) Console.WriteLine(
quot;
{field.Name}: {string.Join(" ", field.Value.EnumerateArray().Select(e => e.GetString()))}");
else Console.WriteLine(
quot;
{json}");
} await Post("Broken input", new { name = "", price = 0, sku = "mouse 1" }); await Post("Sale price too high", new { name = "Wireless Mouse", price = 799, salePrice = 899, sku = "MSE-1" }); await Post("Duplicate SKU", new { name = "USB-C Hub", price = 1499, sku = "HUB-2" }); await Post("Valid", new { name = "Laptop Stand", price = 1299, salePrice = 999, sku = "STD-4" }); await app.StopAsync(); // Contracts/CreateProductDto.cs using System.ComponentModel.DataAnnotations; public class CreateProductDto : IValidatableObject { [Required, StringLength(100, MinimumLength = 2)] public string Name { get; set; } = ""; [Range(1, 1_000_000)] public decimal Price { get; set; } public decimal? SalePrice { get; set; } [Required, RegularExpression("^[A-Z]{2,5}-\\d{1,4}
quot;
, ErrorMessage = "SKU must look like ABC-123.")]
public string Sku { get; set; } = ""; public IEnumerable<ValidationResult> Validate(ValidationContext context) // cross-field rule { if (SalePrice >= Price) yield return new ValidationResult("Sale price must be lower than the price.", [nameof(SalePrice)]); } } // Controllers/ProductsController.cs using Microsoft.AspNetCore.Mvc; [ApiController] [Route("api/[controller]")] public class ProductsController : ControllerBase { private static readonly HashSet<string> Skus = ["MSE-1", "HUB-2"]; [HttpPost] public IActionResult Create(CreateProductDto dto) { if (Skus.Contains(dto.Sku)) // needs data: checked in code { ModelState.AddModelError(nameof(dto.Sku),
quot;SKU
{dto.Sku} is already used.");
return ValidationProblem(ModelState); } Skus.Add(dto.Sku); return Created(
quot;/api/products/
{dto.Sku}", new { dto.Sku, dto.Name });
} }
Expected output
Broken input: 400
  Sku: SKU must look like ABC-123.
  Name: The Name field is required. 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 1 and 1000000.
Sale price too high: 400
  SalePrice: Sale price must be lower than the price.
Duplicate SKU: 400
  Sku: SKU HUB-2 is already used.
Valid: 201
  {"sku":"STD-4","name":"Laptop Stand"}

This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.

Step-by-step explanation#

  1. The broken input fails three attributes; [ApiController] returns 400 with an errors object keyed by field.
  2. The second request passes the attributes, so IValidatableObject runs and flags SalePrice.
  3. The duplicate SKU needs a data check, done in the action and returned with ValidationProblem — the same shape.
  4. The valid request is created with 201.
  5. Clients read errors[field] and show the messages next to the right inputs.

Real-world use case#

ShopSmart’s Angular admin maps errors.Name, errors.Price and errors.Sku to form controls automatically, so every validation rule written on the server shows up in the UI without extra code.

Common mistakes#

Validating in the client only

Anyone can call the API with curl. Server validation is mandatory.

Different error formats

Returning BadRequest("Duplicate SKU") as plain text while other errors are problem details forces clients to handle two shapes. Use ValidationProblem.

Validating entities instead of DTOs

Validation rules belong to the API contract (input DTO), which can differ from database rules.

Best practices#

  • Annotations on input DTOs
  • IValidatableObject or FluentValidation for complex rules
  • ValidationProblem for code-based errors
  • Friendly, specific messages

Remember this#

[ApiController] + annotations = automatic 400.

errors: { field: [messages] }.

Data-dependent rule → AddModelError + ValidationProblem.

Practice exercise#

Your task: Customise the automatic 400 response so it also includes an "errorCount" extension with the number of invalid fields.

Need a hint?
builder.Services.Configure<ApiBehaviorOptions>(o => o.InvalidModelStateResponseFactory = ctx => { var problem = new ValidationProblemDetails(ctx.ModelState); problem.Extensions["errorCount"] = problem.Errors.Count; return new BadRequestObjectResult(problem); });
Show solution
C# Illustrative
// Program.cs
using Microsoft.AspNetCore.Mvc;


var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.Configure<ApiBehaviorOptions>(options =>
    options.InvalidModelStateResponseFactory = context =>
    {
        var problem = new ValidationProblemDetails(context.ModelState) { Status = StatusCodes.Status400BadRequest };
        problem.Extensions["errorCount"] = problem.Errors.Count;
        return new BadRequestObjectResult(problem) { ContentTypes = { "application/problem+json" } };
    });
var app = builder.Build();
app.MapControllers();
app.Run();

Mini quiz#

Model Validation quizMultiple choice Question 1 of 3

What does an [ApiController] action do when the model is invalid?

Score so far: 0/0

Want more? Practise all ASP.NET Core Web API MCQs · ASP.NET Core Web API interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.