Validation in ASP.NET Core Web API is a pipeline — not a single check. Model binding reads the request body, DataAnnotations run against the bound model, and [ApiController] short-circuits with a 400 response if any annotation fails — all before your action method runs. Understanding each stage lets you place validation rules at the right layer and predict exactly what the Angular client will receive when validation fails.
The Full Validation Pipeline
// ── Stage 1: Model Binding ─────────────────────────────────────────────────
// JSON body → CreatePostRequest properties
// Route values → simple type parameters
// Query string → [FromQuery] parameters
// If binding itself fails (wrong type: "abc" for an int), ModelState gets a
// binding error before annotations even run.
// ── Stage 2: DataAnnotations (run by model binder) ────────────────────────
public record CreatePostRequest
{
[Required] // fails if null or empty string
[StringLength(200, MinimumLength = 5)]
public string Title { get; init; } = string.Empty;
[Required]
public string Body { get; init; } = string.Empty;
[Required]
[RegularExpression(@"^[a-z0-9-]+$")]
public string Slug { get; init; } = string.Empty;
}
// ── Stage 3: [ApiController] auto-400 (action never runs if ModelState invalid) ──
// 400 response body (ValidationProblemDetails):
// {
// "type": "https://tools.ietf.org/html/rfc7807",
// "title": "One or more validation errors occurred.",
// "status": 400,
// "errors": {
// "title": ["The title field is required."],
// "slug": ["Slug must contain only lowercase letters, numbers, and hyphens."]
// }
// }
// ── Stage 4: Action body (only reached when ModelState.IsValid == true) ───
[HttpPost]
public async Task<ActionResult<PostDto>> Create(
CreatePostRequest request, CancellationToken ct)
{
// ModelState is valid here — DataAnnotations all passed
// Stage 4 async business validation:
if (await _service.SlugExistsAsync(request.Slug, ct))
{
// Add to ModelState and return 400
ModelState.AddModelError(nameof(request.Slug),
"This slug is already in use.");
return ValidationProblem(ModelState); // 400 with field error
}
var post = await _service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = post.Id }, post);
}
Note:
ValidationProblem(ModelState) is the preferred way to return a 400 with field-specific errors from within an action method. It generates the same ValidationProblemDetails format that [ApiController]‘s automatic 400 produces — so Angular’s error handling code only needs to understand one error format regardless of whether the error came from automatic validation or manual action-body validation. Using BadRequest(ModelState) works too but produces a slightly different response shape.Tip: Distinguish syntactic validation (format checks: required, length, regex) from semantic validation (business rules: slug uniqueness, user exists, sufficient balance). Syntactic validation belongs in DataAnnotations or FluentValidation — it should run before any service layer code. Semantic validation belongs in the service/application layer — it requires database access and is inherently async. Keep these two concerns at their respective layers for testability and clarity.
Warning: The automatic
[ApiController] 400 response fires before the action body runs. This means logging, audit filters, or any action-level processing you put in the action body is skipped for invalid requests. If you need to log all incoming requests — including invalid ones — use a filter with an Order lower than -2000 (the ModelState validation filter’s order) or use request logging middleware, which runs for every request regardless of validation outcome.Customising the Automatic 400 Response
// ── Customise the ValidationProblemDetails shape ───────────────────────────
builder.Services.Configure<ApiBehaviorOptions>(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
var errors = context.ModelState
.Where(ms => ms.Value?.Errors.Any() == true)
.ToDictionary(
ms => ms.Key, // property name (camelCase with [ApiController])
ms => ms.Value!.Errors.Select(e => e.ErrorMessage).ToArray());
var problemDetails = new ValidationProblemDetails(context.ModelState)
{
Type = "https://blogapp.com/errors/validation",
Title = "Validation failed.",
Status = StatusCodes.Status400BadRequest,
Instance = context.HttpContext.Request.Path,
};
// Add correlation ID for log matching
problemDetails.Extensions["traceId"] =
context.HttpContext.TraceIdentifier;
return new BadRequestObjectResult(problemDetails)
{
ContentTypes = { "application/problem+json" }
};
};
});
Common Mistakes
Mistake 1 — Manually checking ModelState.IsValid when [ApiController] is present
❌ Wrong — redundant; action never runs with invalid ModelState anyway:
[ApiController]
public async Task<IActionResult> Create(CreatePostRequest r)
{
if (!ModelState.IsValid) return BadRequest(ModelState); // dead code!
✅ Correct — remove the IsValid check; trust [ApiController] to handle it.
Mistake 2 — Mixing BadRequest(ModelState) and ValidationProblem(ModelState) (inconsistent shapes)
❌ Wrong — two different error shapes; Angular needs two parsing paths.
✅ Correct — always use return ValidationProblem(ModelState) for field-specific errors.