Errores de validación en ASP.NET Core: normalizar ModelState a { field, message }[]
Normaliza los errores de ModelState con InvalidModelStateResponseFactory al formato { field, message }[] para que el cliente muestre validaciones de forma consistente.
Con [ApiController], ASP.NET Core valida automáticamente los modelos entrantes. Si el body de la solicitud es inválido,
el framework devuelve 400 Bad Request con un payload de validación. La respuesta por defecto es utilizable, pero la estructura
errors suele ser un diccionario (field -> string[]) y eso obliga a hacer “flatten” y parseo extra del lado del cliente.
Un enfoque más limpio es normalizar los errores de validación a un formato único y predecible:
{ field, message }[]. Así, la UI (resaltado de campos, toasts, lista de errores, etc.) siempre itera el mismo array.
Objetivo
- Mantener una semántica HTTP correcta (
400para errores de validación). - Devolver los errores en un formato consistente:
{ field, message }[]. - Aplicarlo globalmente (sin repetir código en cada controller).
- Bonus: devolver nombres de campo en camelCase para seguir convenciones típicas de JSON.
Respuesta ModelState por defecto (¿qué llega hoy?)
Con [ApiController], las solicitudes inválidas suelen producir un payload como este (simplificado):
{
"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." ]
}
}
El problema no es el status code, sino la forma (shape): el cliente normalmente debe convertir ese diccionario en una lista para poder mostrar los errores de manera consistente.
Formato objetivo (normalizado)
En lugar de un diccionario, devolvemos una lista simple:
{
"message": "La validación falló.",
"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." }
]
}
Fíjate que los nombres de campo están en camelCase. Este pequeño detalle suele reducir el trabajo de mapeo en la UI.
Paso 1: definir un DTO pequeño para errores
public sealed record FieldError(string Field, string Message);
Paso 2: “aplanar” ModelState a { field, message }[] (con un pequeño toque de C# 12)
ModelState guarda los errores como un diccionario, y cada clave puede tener múltiples mensajes.
El helper siguiente lo convierte en una lista plana. Además, normalizamos el nombre del campo a camelCase usando
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)
{
// Pequeño toque de C# 12: normalizar nombres de campo a camelCase
// (funciona mejor con claves simples como "Email", "Guests")
var field = JsonNamingPolicy.CamelCase.ConvertName(kvp.Key);
foreach (var err in kvp.Value.Errors)
{
// Si ErrorMessage está vacío, fallback al mensaje de la Exception (raro, pero posible).
var msg = !string.IsNullOrWhiteSpace(err.ErrorMessage)
? err.ErrorMessage
: err.Exception?.Message ?? "Invalid value.";
list.Add(new FieldError(field, msg));
}
}
return list;
}
}
Nota: si tus claves contienen rutas con puntos (por ejemplo request.Email o items[0].Name),
puede convenirte aplicar camelCase por segmento. En muchas APIs, nombres de propiedades simples son suficientes y este enfoque se mantiene minimalista.
Paso 3: sobrescribir la respuesta de validación globalmente (Program.cs)
El hook global más limpio es ConfigureApiBehaviorOptions.
Cuando la validación falla, ASP.NET Core llama a InvalidModelStateResponseFactory.
Ahí devolvemos nuestro payload normalizado.
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 = "La validación falló.",
code = "VALIDATION_ERROR",
errors
};
return new BadRequestObjectResult(payload);
};
});
var app = builder.Build();
app.MapControllers();
app.Run();
A partir de aquí, todas las solicitudes inválidas devolverán automáticamente la misma lista de errores,
sin escribir if (!ModelState.IsValid) en cada controller.
Ejemplo: DTO + endpoint
Con [ApiController], no necesitas validar ModelState manualmente.
Si el modelo es inválido, ASP.NET Core devolverá 400 automáticamente (vía nuestra 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)
{
// ¿Inválido? 400 se devuelve automáticamente (via InvalidModelStateResponseFactory).
return Ok(new { message = "Created" });
}
}
Lado del cliente (ahora muy simple)
Como los errores de validación siempre llegan como lista, el cliente puede mostrarlos de forma consistente:
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 es siempre [{ field, message }]
for (const e of payload.errors || []) {
console.log(e.field, e.message);
}
}
Mejoras comunes
-
Si ya usas un wrapper
Result<T>, mantén las mismas convenciones aquí (misma formacode/errors). -
Agrega un
traceId(o un header) para correlacionar errores de validación con logs, especialmente en producción. -
Si necesitas camelCase para rutas con puntos, aplícalo por segmento (por ejemplo, split por
.y conserva índices como[0]).
TL;DR
[ApiController]ya valida los modelos y devuelve400automáticamente.- Para personalizar globalmente, usa
InvalidModelStateResponseFactory. - Aplana ModelState a
{ field, message }[]para simplificar el código del cliente. - Genera nombres de campo en camelCase con
JsonNamingPolicy.CamelCase.ConvertName.