Wird geladen...

ASP.NET Core Validierungsfehler: ModelState in { field, message }[] normalisieren

Normalisieren Sie ModelState-Fehler mit InvalidModelStateResponseFactory in ein einfaches { field, message }[]-Format, damit Clients Validierung einheitlich darstellen können.

Mit [ApiController] validiert ASP.NET Core eingehende Modelle automatisch. Ist der Request-Body ungültig, liefert das Framework 400 Bad Request und ein Validation-Payload zurück. Die Standardantwort ist zwar nutzbar, aber die errors-Struktur ist meist ein Dictionary (field -> string[]) – und das führt auf Client-Seite oft zu zusätzlichem Parse-/Flatten-Code.

Ein sauberer Ansatz ist, Validierungsfehler in ein einziges, vorhersehbares Format zu normalisieren: { field, message }[]. Dadurch kann die UI (Form-Highlighting, Toasts, Fehlerliste usw.) immer über dasselbe Array iterieren.


Ziel


Standard-ModelState-Antwort (Was kommt heute?)

Mit [ApiController] erzeugen ungültige Requests häufig ein Payload wie dieses (vereinfacht):


{
  "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." ]
  }
}

Das Problem ist nicht der Statuscode, sondern die Form (Shape). Auf Client-Seite muss dieses Dictionary oft zuerst „geflattet“ und in eine Liste umgewandelt werden.


Ziel-Format (Normalize)

Statt eines Dictionaries liefern wir eine einfache Liste zurück:


{
  "message": "Validierung fehlgeschlagen.",
  "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." }
  ]
}

Achten Sie darauf, dass die Feldnamen camelCase sind. Dieses kleine Detail reduziert in vielen Anwendungen den Mapping-Aufwand auf UI-Seite.


Schritt 1: Ein kleines Error-DTO definieren


public sealed record FieldError(string Field, string Message);

Schritt 2: ModelState in { field, message }[] flatten (mit einem kleinen C#-12-Touch)

ModelState speichert Fehler als Dictionary und jeder Key kann mehrere Meldungen enthalten. Der folgende Helper wandelt das in eine flache Liste um. Dabei normalisieren wir den Feldnamen in camelCase über 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)
        {
            // Kleiner C#-12-Touch: Feldnamen in camelCase normalisieren
            // (funktioniert am besten bei einfachen Keys wie "Email", "Guests")
            var field = JsonNamingPolicy.CamelCase.ConvertName(kvp.Key);

            foreach (var err in kvp.Value.Errors)
            {
                // Wenn ErrorMessage leer ist, auf Exception-Message fallbacken (selten, aber möglich).
                var msg = !string.IsNullOrWhiteSpace(err.ErrorMessage)
                    ? err.ErrorMessage
                    : err.Exception?.Message ?? "Invalid value.";

                list.Add(new FieldError(field, msg));
            }
        }

        return list;
    }
}

Hinweis: Wenn Ihre Keys „dotted paths“ enthalten (z. B. request.Email oder items[0].Name), möchten Sie ggf. pro Segment camelCase anwenden. Für viele APIs reichen einfache Property-Namen – und dieser Ansatz bleibt angenehm minimal.


Schritt 3: Globale Validation-Antwort überschreiben (Program.cs)

Der sauberste globale Hook ist ConfigureApiBehaviorOptions. Wenn die Model-Validation fehlschlägt, ruft ASP.NET Core InvalidModelStateResponseFactory auf. Genau dort geben wir unser normalisiertes Payload zurück.


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 = "Validierung fehlgeschlagen.",
                code = "VALIDATION_ERROR",
                errors
            };

            return new BadRequestObjectResult(payload);
        };
    });

var app = builder.Build();

app.MapControllers();
app.Run();

Ab hier liefern alle ungültigen Requests automatisch dieselbe normalisierte Fehlerliste zurück. Wiederholte if (!ModelState.IsValid)-Blöcke in Controllern sind nicht mehr nötig.


Beispiel: DTO + Endpoint

Mit [ApiController] müssen Sie ModelState nicht manuell prüfen. Ist das Model ungültig, liefert ASP.NET Core automatisch 400 (über unsere 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)
    {
        // Ungültig? 400 kommt automatisch (via InvalidModelStateResponseFactory).
        return Ok(new { message = "Created" });
    }
}

Client-Seite (jetzt sehr einfach)

Da Validierungsfehler immer als Liste kommen, kann der Client sie konsistent darstellen:


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 ist immer [{ field, message }]
  for (const e of payload.errors || []) {
    console.log(e.field, e.message);
  }
}

Häufige Verbesserungen


TL;DR

  • [ApiController] validiert Modelle bereits und liefert automatisch 400 zurück.
  • Für eine globale Anpassung InvalidModelStateResponseFactory verwenden.
  • ModelState in { field, message }[] flattenen, um Client-Code zu vereinfachen.
  • Standardmäßig camelCase-Feldnamen mit JsonNamingPolicy.CamelCase.ConvertName erzeugen.