Erreurs de validation ASP.NET Core : normaliser ModelState en { field, message }[]
Normalisez les erreurs ModelState via InvalidModelStateResponseFactory au format { field, message }[] afin que le client gère la validation de manière cohérente.
Avec [ApiController], ASP.NET Core valide automatiquement les modèles reçus. Si le body de la requête est invalide,
le framework renvoie 400 Bad Request avec un payload de validation. La réponse par défaut est utilisable, mais la structure
errors est généralement un dictionnaire (field -> string[]), ce qui impose souvent un traitement “flatten” côté client.
Une approche plus propre consiste à normaliser les erreurs de validation dans un format unique et prévisible :
{ field, message }[]. Ainsi, côté UI (surbrillance des champs, toasts, liste d’erreurs, etc.), il suffit toujours
d’itérer sur le même tableau.
Objectif
- Conserver une sémantique HTTP correcte (
400pour les erreurs de validation). - Renvoyer les erreurs dans un format unique :
{ field, message }[]. - Appliquer la règle globalement (sans répéter du code dans chaque controller).
- Bonus : renvoyer des noms de champs en camelCase pour coller aux conventions JSON.
Réponse ModelState par défaut (ce que vous obtenez aujourd’hui)
Avec [ApiController], une requête invalide produit souvent un payload comme ceci (simplifié) :
{
"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." ]
}
}
Le problème n’est pas le status code, mais la forme (shape) : côté client, il faut généralement transformer ce dictionnaire en liste pour l’afficher proprement.
Format cible (normalisé)
Au lieu d’un dictionnaire, on renvoie une liste simple :
{
"message": "Échec de validation.",
"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." }
]
}
Remarquez que les noms de champs sont en camelCase. Ce petit détail réduit souvent le travail de mapping côté UI.
Étape 1 : définir un petit DTO d’erreur
public sealed record FieldError(string Field, string Message);
Étape 2 : aplatir ModelState en { field, message }[] (avec une petite touche C# 12)
ModelState stocke les erreurs dans un dictionnaire, et chaque clé peut contenir plusieurs messages.
Le helper ci-dessous transforme cela en liste “flat”. Pendant cette conversion, on normalise aussi le nom du champ en camelCase
via 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)
{
// Petite touche C# 12 : normaliser les noms de champs en camelCase
// (idéal pour des clés simples comme "Email", "Guests")
var field = JsonNamingPolicy.CamelCase.ConvertName(kvp.Key);
foreach (var err in kvp.Value.Errors)
{
// Si ErrorMessage est vide, fallback sur le message de l’Exception (rare mais possible).
var msg = !string.IsNullOrWhiteSpace(err.ErrorMessage)
? err.ErrorMessage
: err.Exception?.Message ?? "Invalid value.";
list.Add(new FieldError(field, msg));
}
}
return list;
}
}
Remarque : si vos clés contiennent des chemins (ex. request.Email ou items[0].Name),
il peut être préférable d’appliquer le camelCase par segment. Dans beaucoup d’API, des noms de propriétés simples suffisent,
et cette version reste volontairement minimaliste.
Étape 3 : surcharger la réponse de validation globalement (Program.cs)
Le hook global le plus propre est ConfigureApiBehaviorOptions.
Quand la validation échoue, ASP.NET Core appelle InvalidModelStateResponseFactory.
C’est ici qu’on renvoie notre payload normalisé.
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 = "Échec de validation.",
code = "VALIDATION_ERROR",
errors
};
return new BadRequestObjectResult(payload);
};
});
var app = builder.Build();
app.MapControllers();
app.Run();
À partir de là, toutes les requêtes invalides renverront automatiquement la même liste d’erreurs, sans écrire
if (!ModelState.IsValid) dans chaque controller.
Exemple : DTO + endpoint
Avec [ApiController], vous n’avez pas besoin de vérifier ModelState à la main.
Si le modèle est invalide, ASP.NET Core renverra automatiquement 400 via notre 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)
{
// Invalide ? 400 est renvoyé automatiquement (via InvalidModelStateResponseFactory).
return Ok(new { message = "Created" });
}
}
Côté client (beaucoup plus simple)
Comme les erreurs arrivent toujours sous forme de liste, le client peut les afficher de manière uniforme :
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 est toujours [{ field, message }]
for (const e of payload.errors || []) {
console.log(e.field, e.message);
}
}
Améliorations courantes
-
Si vous utilisez déjà un wrapper
Result<T>, gardez les mêmes conventions ici (mêmecode/ même formeerrors). -
Ajoutez un
traceId(ou un header) pour corréler les erreurs de validation avec les logs, surtout en production. -
Si le camelCase est nécessaire pour des “dotted paths”, appliquez-le par segment (split sur
.en conservant les index comme[0]).
TL;DR
[ApiController]valide déjà les modèles et renvoie automatiquement400.- Pour personnaliser globalement : utilisez
InvalidModelStateResponseFactory. - Aplatissez ModelState en
{ field, message }[]pour simplifier le code client. - Générez des champs en camelCase via
JsonNamingPolicy.CamelCase.ConvertName.