ASP.NET Core Validation Errors: Normalize ModelState to { field, message }[]
Normalize ASP.NET Core ModelState errors into a simple { field, message }[] format using InvalidModelStateResponseFactory, so clients can render validation consistently.
With [ApiController], ASP.NET Core automatically validates incoming models. If the request body is invalid,
the framework returns 400 Bad Request with a validation payload. That default response is usable, but the
errors structure is typically a dictionary (field -> string[]), which often leads to extra client-side parsing.
A cleaner approach is to normalize validation errors into a single, predictable format such as
{ field, message }[]. This makes UI code simpler (forms, toasts, field highlights) because the client
always loops the same array.
Goal
- Keep HTTP semantics correct (
400for validation errors). - Return validation errors in one consistent format:
{ field, message }[]. - Apply it globally (no repeated code in every controller).
- Bonus: return field names in camelCase to match typical JSON conventions.
Default ModelState Response (What You Get Today)
With [ApiController], invalid requests often produce a payload like this (simplified):
{
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Email": [ "The Email field is not a valid e-mail address." ],
"Guests": [ "The field Guests must be between 1 and 10." ]
}
}
The issue is not the status code—it’s the shape: clients usually need to flatten the dictionary before they can render errors consistently.
Target Shape (Normalized)
Instead of a dictionary, return a simple list:
{
"message": "Validation failed.",
"code": "VALIDATION_ERROR",
"errors": [
{ "field": "email", "message": "The Email field is not a valid e-mail address." },
{ "field": "guests", "message": "The field Guests must be between 1 and 10." }
]
}
Notice the field names are camelCase. This tiny detail reduces UI mapping work in many apps.
Step 1: Define a Small Error DTO
public sealed record FieldError(string Field, string Message);
Step 2: Flatten ModelState into { field, message }[] (with a small C# 12 touch)
ModelState stores errors as a dictionary where each key can have multiple messages.
The helper below turns it into a flat list. While doing that, we also convert the field name to camelCase using:
JsonNamingPolicy.CamelCase.ConvertName(...).
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.ModelBinding;
public static class ModelStateExtensions
{
public static List<FieldError> ToFieldErrors(this ModelStateDictionary modelState)
{
var list = new List<FieldError>();
foreach (var kvp in modelState)
{
// Small C# 12 touch: normalize field names to camelCase
// (Works best for simple keys like "Email", "Guests")
var field = JsonNamingPolicy.CamelCase.ConvertName(kvp.Key);
foreach (var err in kvp.Value.Errors)
{
// If ErrorMessage is empty, fallback to Exception message (rare, but possible).
var msg = !string.IsNullOrWhiteSpace(err.ErrorMessage)
? err.ErrorMessage
: err.Exception?.Message ?? "Invalid value.";
list.Add(new FieldError(field, msg));
}
}
return list;
}
}
Note: if your keys include dotted paths (e.g. request.Email or items[0].Name),
you may want to camelCase per segment. For many APIs, simple property names are enough and this stays nicely minimal.
Step 3: Override the Global Validation Response (Program.cs)
The cleanest global hook is ConfigureApiBehaviorOptions.
When model validation fails, ASP.NET Core calls InvalidModelStateResponseFactory.
We return our normalized payload there.
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddControllers()
.ConfigureApiBehaviorOptions(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
var errors = context.ModelState.ToFieldErrors();
var payload = new
{
message = "Validation failed.",
code = "VALIDATION_ERROR",
errors
};
return new BadRequestObjectResult(payload);
};
});
var app = builder.Build();
app.MapControllers();
app.Run();
From this point on, every invalid request automatically returns the same normalized error list—without writing
repeated if (!ModelState.IsValid) blocks in controllers.
Example: DTO + Endpoint
With [ApiController], you don’t need to manually check ModelState. If the model is invalid, ASP.NET Core returns
400 automatically using our factory.
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;
public sealed class CreateReservationRequest
{
[Required, EmailAddress]
public string Email { get; set; } = "";
[Range(1, 10)]
public int Guests { get; set; }
}
[ApiController]
[Route("api/reservations")]
public sealed class ReservationsController : ControllerBase
{
[HttpPost]
public IActionResult Create([FromBody] CreateReservationRequest request)
{
// If invalid: 400 is returned automatically (via InvalidModelStateResponseFactory).
return Ok(new { message = "Created" });
}
}
Client Side (Now Very Simple)
Since validation errors always arrive as a list, the client can render them consistently:
const res = await fetch("/api/reservations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "not-an-email", guests: 100 })
});
if (!res.ok) {
const payload = await res.json();
// payload.errors is always [{ field, message }]
for (const e of payload.errors || []) {
console.log(e.field, e.message);
}
}
Common Improvements
-
If you already use a
Result<T>wrapper, keep the same conventions here (samecode/errorsshape). -
Add a
traceIdfield (or header) to correlate validation issues with logs (especially helpful in production). -
If you need camelCase for dotted paths, camelCase each segment (e.g., split by
.and keep indexes like[0]).
TL;DR
[ApiController]already validates models and returns400automatically.- Use
InvalidModelStateResponseFactoryto customize that response globally. - Flatten ModelState into
{ field, message }[]to simplify client-side code. - Use
JsonNamingPolicy.CamelCase.ConvertNameto output camelCase field names by default.