Model validation and Problem Details responses
Data annotations, automatic 400 responses and a consistent error shape that a client can parse without guessing.
Annotations and custom validators
using System.ComponentModel.DataAnnotations;
public sealed class CreateOrder
{
[Required, StringLength(40, MinimumLength = 1)]
public string Sku { get; set; } = "";
[Range(1, 10_000)]
public int Qty { get; set; } = 1;
[EmailAddress]
public string? ContactEmail { get; set; }
[RegularExpression("^[A-Z]{3}$")]
public string Currency { get; set; } = "GBP";
[ValidateComplexType]
public ShippingAddress? ShipTo { get; set; }
}
public sealed class ShippingAddress
{
[Required] public string Line1 { get; set; } = "";
[Required] public string Postcode { get; set; } = "";
}
// A validator that needs a service is not an attribute: use IValidatableObject
// or a custom ValidationAttribute with a service behind a static accessor.
public sealed class DeliveryWindow : ValidationAttribute
{
public override bool IsValid(object? value) =>
value is DateTimeOffset d && d > DateTimeOffset.UtcNow;
}- Data annotations are declarative and easy to read, but they cannot access a database or a scoped service. Reach for
IValidatableObjector a FluentValidation-style library when the rule needs data. - Validating a nested object requires
[ValidateComplexType]in minimal APIs and MVC; without it the nested object is not validated at all. - Empty strings and null are treated differently by
[Required]; the default rejects null and, for strings, empty strings too. - Do not reuse a persistence entity as the request model. Write a request DTO and map explicitly, so a new column does not become a new writable field.
Problem Details, automatically
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Sku": ["The Sku field is required."],
"Qty": ["The field Qty must be between 1 and 10000."]
},
"traceId": "00-9f2b1c4d5e6f-01"
}// Add trace correlation to every problem document, including the automatic ones
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = ctx =>
{
ctx.ProblemDetails.Extensions["traceId"] =
Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier;
};
});
// The validation filter builds ValidationProblemDetails for you, with an
// "errors" dictionary keyed by the JSON property name. Suppress it only when
// you want to produce that shape yourself.
builder.Services.Configure<ApiBehaviorOptions>(o =>
{
o.SuppressModelStateInvalidFilter = false;
});
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
public static IResult RejectOrder(string code, string detail) =>
Results.Problem(
title: "The order cannot be accepted",
detail: detail,
statusCode: StatusCodes.Status422UnprocessableEntity,
extensions: new Dictionary<string, object?> { ["code"] = code });| Situation | Status | Body |
|---|---|---|
| Malformed JSON | 400 | A problem document naming the parse failure |
| Missing or invalid field | 400 | A problem document with an errors dictionary |
| Business rule rejected the request | 422 | A problem document with your own error code |
| Not authenticated | 401 | Empty body, plus a WWW-Authenticate header |
| Not permitted | 403 | Empty body by default |
| Unhandled exception | 500 | A problem document with no internal detail in production |
| Wrong content type | 415 | A problem document naming the supported type |
💡
Return a stable machine-readable code alongside the human message. Clients will write conditions against
order.sku.unknown long before they parse your English text, and the text will change with the first localisation.FAQ
Why does my custom validation never run?
Because model binding failed first, so the validator never sees the object. Check whether the failures in ModelState come from binding or from validation — the error keys tell you.
Should I return 400 or 422?
400 when the request is syntactically wrong or fails schema validation, 422 when the request is well formed but the business rules reject it. Pick one convention and document it.
Related
Routing and model binding Error handling, health checks and resilience
Last refreshed 2026-09-18.