1. Selbstgemachtes JSON und der Zoo der Fehler
Wenn ein Projekt klein ist, scheint es möglich, sich zu erlauben, als Antwort auf einen Fehler etwas wie Map<String, Object> oder einen kleinen record mit ein paar Feldern zurückzugeben. Das sieht niedlich aus, bis der zweite Controller, der dritte Endpunkt und das erste „komplexe“ negative Szenario auftauchen. In diesem Moment stellt sich heraus, dass ein Endpunkt {"error":"..."}, ein anderer {"message":"..."}, ein dritter {"status":400,"details":[...]} zurückgibt und ein vierter plötzlich beginnt, Plaintext zu liefern. Der Client möchte nach so etwas entweder weinen, einen 500-Zeilen-Adapter schreiben oder, im schlimmsten Fall, „alles abfangen und dem Benutzer zeigen, dass etwas schief gelaufen ist“.
Der unangenehmste Teil: Das Chaos hat einen Schneeballeffekt. Sobald man drei verschiedene Fehlerformate hat, wird man fast garantiert sieben bekommen. Denn jeder neue Entwickler – oder Sie selbst zwei Wochen später – wird ein „weiteres kleines Format“ hinzufügen, weil es ja ein besonderer Fall ist. Und das ist nicht einmal böse Absicht – es gibt einfach keinen Standard, an den man sich halten kann. Wir brauchen eine gemeinsame Basis, die gleichermaßen für 400, 404, 409 und sogar für den „unerwarteten 500“ geeignet ist und es erlaubt, etwas projektspezifische Details ohne Anarchie hinzuzufügen.
Problem Details und ProblemDetail in Spring
Kurz gesagt: Problem Details ist ein standardisierter Ansatz, wie eine API einen Fehler in JSON beschreibt. In Spring wird dieser Standard durch die Klasse ProblemDetail repräsentiert. Sie dient nicht dazu, eine Exception „schön zu serialisieren“, sondern einen einheitlichen, vorher verständlichen Container für die Fehlerfelder bereitzustellen. Im Grunde ist es wie eine Box mit verständlichen Abteilen: Wo ist die Überschrift, wo die detaillierte Beschreibung, wo der Status, wo der Verweis auf den Problemtyp.
Wichtig ist, den richtigen Gedanken zu erfassen: ProblemDetail ist kein Ersatz für ingenieurstechnisches Denken über Fehler, sondern eine Form, in die Sie das bereits durchdachte Ergebnis einfügen. Das heißt, Sie müssen immer noch verstehen, warum das ein 404 und kein 400 ist; warum es ein 409 und kein 500 ist; und warum der Detailtext sich auf die Ressource und Regeln beziehen sollte, nicht auf NullPointerException in Zeile 57.
Damit nicht der Eindruck eines „weiteren abstrakten Standards“ entsteht, schauen wir uns an, wie ein typisches ProblemDetail in JSON aussieht. Das Beispiel ist bedingt, die Felder können im Detail abweichen, aber die Idee bleibt erhalten:
{
"type": "about:blank",
"title": "Task not found",
"status": 404,
"detail": "Task with id '...' was not found",
"instance": "/api/v1/tasks/..."
}
Diese Felder kann man als minimales Wörterbuch betrachten:
| Feld | Warum der Client es braucht |
|---|---|
| type | Kategorie/Typ des Problems als URI (lassen oft about:blank, wenn kein Katalog geführt wird) |
| title | Kurzer Titel „was passiert ist“ |
| status | HTTP-Status, aber zur Bequemlichkeit der Client-Bibliotheken im Body dupliziert |
| detail | Detail „warum“ und „was falsch ist“ in menschenlesbarem Text |
| instance | „Wo dies passiert ist“ – spezifischer Pfad/URI der Anfrage (oder ein Teil davon) |
Und nun der entscheidende Punkt, der für uns im Task Tracker API besonders wichtig ist: ProblemDetail kann um zusätzliche Eigenschaften erweitert werden. Und das geschieht nicht über eine Map mit unverständlichen Schlüsseln, sondern über einen expliziten Erweiterungsmechanismus.
Media-Type application/problem+json als Teil des Vertrags
Wenn wir eine erfolgreiche Antwort zurückgeben, liefert unser API fast immer Content-Type: application/json. Das ist gewohnt und bequem. Aber für Fehler gibt es einen separaten standardisierten Media-Type: application/problem+json. Und das ist keine „Marketing-Pingeligkeit“, sondern ein wichtiges Signal an den Client: „In dem JSON steckt kein Datenmodell, sondern eine Problembeschreibung“.
Stellen Sie sich vor, Sie schreiben eine Client-Anwendung. Wenn der Server manchmal application/json mit einem DTO und manchmal application/json mit einem Fehler zurückgibt, muss der Client anhand des Inhalts raten: „Ist das ein Erfolg oder ein Fehler?“. Jemand prüft, ob das Feld error vorhanden ist, jemand schaut auf die Struktur, jemand versucht einfach zu deserialisieren und fängt eine Exception. Das alles ist unzuverlässig und erzeugt fragilen Code.
Mit application/problem+json wird der Vertrag sauberer: Bei einem Fehler erwarten Sie Problem Details und behandeln ihn als Fehler, nicht als „eine der möglichen DTO-Formen“. Auch wenn Sie noch keinen komplexen Client bauen, ist dies eine Disziplin, die sich schnell auszahlt. Und ja, es ist auch für Menschen sehr praktisch: Antwort in Postman oder IDEA HTTP Client geöffnet – sofort ersichtlich, dass es sich um einen Standardfehler handelt, nicht um „zufälliges JSON“.
2. ProblemDetail in Spring Boot 4
Problem Details in Spring MVC aktivieren
In unserem Kurs halten wir die Konfiguration bewusst explizit. Wenn ein Mechanismus für den Vertrag wichtig ist, sollten wir nicht darauf hoffen, dass „er sich von selbst einschaltet“. Für Problem Details im Projekt Task Tracker API legen wir die Unterstützung über eine YAML-Konfiguration fest.
spring:
mvc:
problemdetails:
enabled: true # Explizit Problem Details als Teil des Fehlervertrags aktivieren
Diese Einstellung ist wichtig nicht als „Schönheitsschalter“, sondern als Teil der Disziplin: Die Anwendung erklärt offiziell, dass sie Problem Details als grundlegenden Weg der Fehlerbildung unterstützt. Wenn Fehler in einer einheitlichen Schicht gesammelt werden, ist es mit diesem Format viel einfacher, den Vertrag integer zu halten: Die Grundlage ist für die gesamte Anwendung dieselbe.
Und noch eine kleine, aber nützliche Tatsache: ProblemDetail ist ein gewöhnliches Objekt, das Spring MVC genauso in JSON serialisieren kann wie Ihre DTOs, über denselben HttpMessageConverter. Technisch gesehen ist es also „ein Response-Body wie jeder andere“, nur mit einer anderen Bedeutung und einem anderen Media-Type.
ProblemDetail von Hand erstellen und das Feld code
Jetzt machen wir das Praktischste: Wir lernen, ProblemDetail so zusammenzustellen, dass es sowohl für Menschen als auch für Maschinen nützlich ist. Für Menschen – über title und detail. Für Maschinen – über status und in unserem Projekt über die zusätzliche Eigenschaft code.
Warum brauchen wir code schon jetzt? Weil „menschlicher Text“ in detail unvermeidlich ändert. Heute schreiben Sie Task not found, morgen schreiben Sie es zu Task does not exist um und übermorgen fügen Sie die ID im Text hinzu. Wenn der Client versucht, den Fehlertyp anhand der Zeichenkette zu erkennen, ist er verloren. Deshalb fügen wir einen stabilen Code hinzu, z. B. TASK_NOT_FOUND, INVALID_STATUS_TRANSITION, INVALID_INPUT. Diese Codes sind bereits in der Projektspezifikation festgehalten – wir erfinden also keine neue Welt, sondern machen sie dem Client nur zugänglich.
Ein minimales Beispiel für die Erstellung eines ProblemDetail für 404 kann so aussehen:
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
// Sammelt die standardisierte Fehlerbeschreibung (Problem Details)
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, // HTTP-Status der Fehlers
"Task with id '%s' was not found".formatted(taskId) // Kontrollierter Text für den Client
);
// Kurzer Fehlertitel (bequem in UI/Logs anzuzeigen)
problem.setTitle("Task not found");
// Stabile Maschinenkennung: Der Client erkennt den Fehlertyp daran ohne Textparsing
problem.setProperty("code", "TASK_NOT_FOUND");
Beachten Sie zwei Dinge. Erstens: detail ist nicht ex.getMessage(), sondern Ihr kontrollierter Text in der API-Sprache. Zweitens: code ist genau eine „Maschinenmarkierung“, deren Aufgabe es ist, stabil zu sein, nicht schön.
Für 409 Conflict ist die Logik dieselbe, nur ein anderer Status und eine andere Bedeutung:
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
// Hier benötigen wir detail nicht bei der Erstellung: wir setzen es weiter unten
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.CONFLICT);
problem.setTitle("Status change is not allowed"); // Kurzer Titel für Menschen
problem.setDetail("Transition from DONE to TODO is not allowed"); // Erklärung "was genau falsch ist"
// Stabile Kennung des Geschäftskonflikts (Vertrag mit dem Client)
problem.setProperty("code", "INVALID_STATUS_TRANSITION");
Wenn Sie das Gefühl haben, Codes manuell als Strings zu schreiben, der Weg zu Tippfehlern ist, haben Sie völlig Recht. Eine der einfachsten Möglichkeiten, ein Projekt etwas „robuster“ zu machen, ist, ein Enum für die Codes einzuführen. Das ist keine „Übertreibung“, sondern Schutz vor TASK_NOT_FOND (ja, das kommt vor).
package com.example.tasktracker.api.dto.error;
// Einheitliche Liste von Fehlercodes: Schutz vor Tippfehlern und Autovervollständigung in der IDE
public enum ApiErrorCode {
TASK_NOT_FOUND,
INVALID_STATUS_TRANSITION,
INVALID_INPUT
}
Dann können Sie code.name() verwenden und erhalten garantiert die korrekte Zeichenkette. Und Ihre IDE wird die Autovervollständigung anbieten, anstatt zu raten.
3. Beispiel in einem Controller ohne globalen Interceptor
In einer realen Anwendung wird die Fehlerformatierung nicht über die Controller verstreut. Aber einmal von Hand ein ProblemDetail zu erstellen, ist nützlich: So kann man leichter sehen, welche Felder tatsächlich Teil des Vertrags sind und welche nur technische Details sind. Daher ist das folgende Beispiel geradlinig: ein Endpunkt, ein try/catch, ein ProblemDetail.
Das Format ist hier bereits projektbezogen, der manuelle try/catch jedoch nicht: Sobald diese Umwandlung sich zu wiederholen beginnt, sollte sie aus den Controllern ausgelagert werden.
Angenommen, wir haben bereits einen TaskService, der im Falle einer fehlenden Aufgabe eine TaskNotFoundException wirft. Im Controller wandeln wir dies vorübergehend in ein ProblemDetail um.
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/api/v1/tasks/{taskId}")
public ResponseEntity
getById(@PathVariable String taskId) {
try {
// Erfolgsszenario: Rückgabe eines normalen DTO als application/json
return ResponseEntity.ok(taskService.getById(taskId));
} catch (TaskNotFoundException ex) {
// Fehlerszenario: Erstellen von Problem Details (application/problem+json)
ProblemDetail pd = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, // 404 — Ressource nicht gefunden
"Task with id '%s' was not found".formatted(taskId) // Kontrollierte Nachricht
);
pd.setTitle("Task not found"); // Kurzer Fehlertitel
pd.setProperty("code", "TASK_NOT_FOUND"); // Maschinen-Code für den Client
// Wichtig: korrekten Content-Type für Problem Details setzen
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(pd);
}
}
Hier sind gleich mehrere Vertragssachen gut zu erkennen. Erfolg und Fehler haben unterschiedliche Bedeutungen und unterschiedliche Bodies, und wir setzen explizit application/problem+json für den Fehler. Der Client muss nicht mehr „raten“, was er bekommen hat.
Jetzt fügen wir ein weiteres Beispiel hinzu – einen Geschäftskonflikt. Nehmen wir an, wir haben ein Szenario der Statusänderung. Wenn der Service eine InvalidStatusTransitionException wirft, geben wir 409 und einen Code zurück.
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
public ResponseEntity
changeStatus(String taskId, String newStatus) {
try {
// Erfolg: Status geändert, Ergebnis der Operation zurückgeben
return ResponseEntity.ok(taskService.changeStatus(taskId, newStatus));
} catch (InvalidStatusTransitionException ex) {
// Konflikt der Geschäftsregeln: 409 + Problem Details
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.CONFLICT);
pd.setTitle("Status change is not allowed");
pd.setDetail("This status transition is not allowed for the task");
pd.setProperty("code", "INVALID_STATUS_TRANSITION"); // Stabile Ursache des Konflikts
return ResponseEntity.status(HttpStatus.CONFLICT)
.contentType(MediaType.APPLICATION_PROBLEM_JSON) // application/problem+json
.body(pd);
}
}
Ja, der Text in detail ist hier noch einfach. Und ja, in einer echten API möchte man mehr Details hinzufügen, z. B. den alten und den neuen Status, aber das ist genau der Moment, in dem es wichtig ist, nicht in „zufälliges JSON“ abzudriften. ProblemDetail erlaubt es, Details sauber hinzuzufügen, aber wir müssen dies bewusst und einheitlich im Projekt tun.
Wenn Sie das „Erstellen von ProblemDetail“ aus dem Controller entfernen möchten, aber noch keine globale Behandlung aufbauen wollen, können Sie einen kleinen Factory-Helfer erstellen. Er löst die Architektur nicht vollständig, reduziert aber die Duplizierung erheblich und verringert die Wahrscheinlichkeit, dass „ein Fehler an einer Stelle anders aussieht“.
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
// Kleine Factory für ProblemDetail: reduziert Duplizierung in Controllern
public final class TaskProblems {
private TaskProblems() {
// Verhindert Instanzerstellung: dies ist eine Utility-Klasse
}
public static ProblemDetail taskNotFound(String taskId) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND,
"Task with id '%s' was not found".formatted(taskId)
);
pd.setTitle("Task not found");
pd.setProperty("code", "TASK_NOT_FOUND"); // Vertraglicher Fehlercode
return pd;
}
}
Und hier zeigt sich bereits die Einschränkung des manuellen Ansatzes: ProblemDetail ist gewählt, die Fehlercodes sind klar, aber dieselbe Umwandlung beginnt sich dennoch zu wiederholen. Der nächste natürliche Schritt ist, das Mapping von Exceptions aus den Controllern in eine einheitliche Schicht @ControllerAdvice / @ExceptionHandler auszulagern.
4. Beispiel einer HTTP-Antwort auf der Leitung
Um das Bild zu festigen, stellen wir uns vor, der Client hat GET /api/v1/tasks/does-not-exist aufgerufen. Auf HTTP-Ebene wollen wir nicht „irgendein JSON“ erhalten, sondern eine streng definierte Antwort.
Schematisch kann man das so darstellen:
flowchart LR
C[HTTP client] -->|"GET /api/v1/tasks/{id}"| S[Spring MVC]
S -->|"TaskNotFoundException / Fehlerpfad"| PD[ProblemDetail]
PD -->|"Content-Type: application/problem+json"| C
Der Client sollte etwa Folgendes sehen, vereinfachtes Format, ohne Anspruch auf genaue Zeichensetzung:
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{
"title": "Task not found",
"status": 404,
"detail": "Task with id 'does-not-exist' was not found",
"code": "TASK_NOT_FOUND"
}
Beachten Sie, wie viel bequemer das für den Client ist im Vergleich zu „einfachem String“. Der Client-Code kann status als Reaktionsklasse verarbeiten, code als konkrete Ursache, z. B. „Aufgabe nicht gefunden“ anzeigen, und detail als verständliche Nachricht für den Entwickler oder für Logs. Und dabei geben Sie weder den Stacktrace noch den internen Exceptionsnamen noch „magische“ Java-Diagnostik nach außen.
5. Typische Fehler bei der Arbeit mit ProblemDetail
Fehler Nr. 1: „Wir haben jetzt ProblemDetail, also brauchen wir nicht mehr nachdenken“.
Ein sehr verlockender Gedanke: „Alles, wir sind zum Standard übergegangen, der Rest wird von Spring selbst geregelt.“ Wird er nicht. ProblemDetail ist eine Form, kein Sinn. Wenn Sie fälschlicherweise 400 zurückgeben, wo 404 nötig wäre, oder 409 mit 500 verwechseln, wird ProblemDetail Ihren falschen Sinn nur sauber verpackt machen. Dem Client hilft das nicht.
Fehler Nr. 2: Verwendung von ProblemDetail nur für 500, während andere Fehler „irgendwie“ behandelt werden.
Manchmal machen Entwickler das so: „Für 500 geben wir ProblemDetail zurück, für 404 geben wir einen String zurück, denn da ist ja alles klar.“ In der Praxis bricht das die Vorhersagbarkeit: Der Client muss zwei oder mehr Fehlerformate handhaben. Wenn Sie Problem Details als Grundlage wählen, machen Sie es zur Grundlage für die gesamte API, sonst haben Sie wieder einen „Zoo“, nur etwas modischer.
Fehler Nr. 3: Den Media-Type vergessen und application/json für den Fehler zurückgeben.
Scheinbar eine Kleinigkeit, aber vertraglich relevant. Wenn Sie nicht application/problem+json setzen, könnte der Client versuchen, die Antwort als „normales DTO“ zu behandeln. Besonders wenn der Client einen generischen Deserialisierungsmechanismus basierend auf Content-Type hat. Besser, man diszipliniert sich einmal und gibt immer den korrekten Typ für Problem Details zurück.
Fehler Nr. 4: Technischen Müll in ProblemDetail packen.
Es entsteht die Versuchung: „Da ProblemDetail ein Feld detail hat, legen wir ex.getMessage() hinein.“ Das ist ein rutschiger Weg: Exceptions-Nachrichten sind instabil, enthalten oft interne Details und manchmal sensible Informationen. detail sollte kontrollierter Text in der API-Sprache sein. Diagnostik lebt in Logs, nicht im Vertrag.
Fehler Nr. 5: „Zufällige“ zusätzliche Felder statt stabiler.
setProperty ist eine nützliche Sache, aber keine Einladung, eine Müllhalde zu errichten. Wenn Sie heute debugMessage, morgen stack, übermorgen exceptionClass hinzufügen, machen Sie die Erweiterung zu einem unvorhersehbaren Feld „alles Mögliche“. Fügen Sie nur das hinzu, was der Client wirklich braucht und was Sie als Vertrag pflegen wollen. In unserem Projekt ist code ein gutes Beispiel, weil er vorab vereinbart und stabil ist.
GO TO FULL VERSION