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.
On this page · 7 min · Beginner
On this page
- In simple words
- Learning objectives
- Explanation
- Syntax
- Code example
- Step by step
- Real-world use
- Common mistakes
- Best practices
- Remember this
- Practice exercise
- Mini quiz
- Related topics
- My notes
- Reading time
- 7 min
- Difficulty
- Beginner
- Updated
- Oct 29, 2026
Related tutorials
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#
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#
// 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 });
}
}
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#
- The broken input fails three attributes; [ApiController] returns 400 with an errors object keyed by field.
- The second request passes the attributes, so IValidatableObject runs and flags SalePrice.
- The duplicate SKU needs a data check, done in the action and returned with ValidationProblem — the same shape.
- The valid request is created with 201.
- 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#
Anyone can call the API with curl. Server validation is mandatory.
Returning BadRequest("Duplicate SKU") as plain text while other errors are problem details forces clients to handle two shapes. Use ValidationProblem.
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?
Show solution
// 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#
What does an [ApiController] action do when the model is invalid?
Want more? Practise all ASP.NET Core Web API MCQs · ASP.NET Core Web API interview questions
My notes#
Saved in this browser.