Fehler mit Format: restliche Endpunkte, Mapper und Problem Details nach RFC 9457
https://bildung.social/@oerinformatik/
https://oer-informatik.de/spring-boot-rest-api-fehlerbehandlung
REST-API, Fehlerbehandlung, Problem Details, RFC 9457, ProblemDetail, RestControllerAdvice, ExceptionHandler, HTTP-Statuscode, Mapper, DTO, Flurmonitor
tl/dr; (ca. 26 min Lesezeit): Seit Artikel 08 greifen die Fachregeln, aber jeder Verstoß endet als
500 Internal Server Error. Jetzt bekommt jede fachliche Ausnahme den Statuscode aus dem Vertrag:404,409oder422, beschrieben als Problem Details nach RFC 9457. Eine zentrale Klasse mit@RestControllerAdviceübernimmt das für alle Controller, auch für Vertragsverstöße (400). Dazu kommen die restlichen Endpunkte (Angebot ändern und absagen, Buchungen eines Angebots, Flurmonitor) und eigene Mapper-Klassen für die Umwandlung zwischen Entities und Vertragsmodellen [AI-Assisted]. (Zuletzt geändert am 11.11.2026)
Dieser Artikel ist Teil einer Artikelreihe über ein REST-Backend mit Spring Boot: Überblick und Fahrplan / 01 Anforderungen und API-Vertrag / 02 Projekt anlegen / 03 Codegenerierung / 04 Fachlogik und Komponententest / 05 Build-Qualität und CI / 06 Persistenz mit JPA / 07 Repository-Integrationstest / 08 Service und Dependency Injection / 09 REST-API und Fehlerbehandlung / Übungsaufgaben zu 09
Schon fit in diesem Thema? Nutze die Kann-Liste und die Leitfragen unter dem Artikel als Checkliste, ob Du die relevanten Kompetenzen bereits hast.
Dieser Artikel baut auf der Kann-Liste der Artikel Anforderungen und API-Vertrag, Service-Schicht und Dependency Injection und REST-Grundlagen auf.
Als Grundlagen zur Fehlerbehandlung einer REST-API kann ich nach Durchlesen des Artikels…
… die Statuscodes
400,404,409,422und500voneinander abgrenzen und einer Fehlersituation zuordnen.… den Aufbau einer Fehlerantwort nach RFC 9457 (Problem Details) mit
type,title,status,detailundinstanceerklären.… mit
@RestControllerAdviceund@ExceptionHandlerfachliche Ausnahmen an einer zentralen Stelle in Fehlerantworten übersetzen.… begründen, warum die Umwandlung zwischen Entities und Vertragsmodellen in eigene Mapper-Klassen gehört.
… die restlichen Endpunkte eines Vertrags (ändern, Unterressourcen auflisten, gefilterte Listen mit Obergrenze) in Controller, Service und Repository umsetzen.
Darüber hinaus kann ich als Expertenwissen zur Fehlerbehandlung…
… erklären, warum ein
@Validated-Interface bei falschen Query-Parametern eineConstraintViolationExceptionauslöst und wie daraus eine400-Antwort wird.… eine
LazyInitializationExceptiondeuten und mitjoin fetchbeheben.… einschätzen, welche Informationen eine Fehlerantwort enthalten darf und welche nicht.
Nach Artikel 08 prüft der Service des Mitfahrlabors alle Fachregeln. Er wirft dafür fachliche Ausnahmen wie KostenbeitragZuHochException oder AngebotNichtBuchbarException. Beim Client kommt davon aber nur eine nichtssagende Antwort an:
HTTP/1.1 500
Content-Type: application/json
{"timestamp":"2026-11-10T00:44:22.345Z","status":500,"error":"Internal Server Error","path":"/v1/angebote"}
Der Vertrag aus Artikel 01 verspricht etwas anderes: 422, wenn eine Fachregel verletzt ist, 409 bei einem Zustandskonflikt, 404, wenn es eine ID nicht gibt, und in allen Fällen eine Fehlerbeschreibung im Format application/problem+json. Dieser Artikel löst das Versprechen ein und setzt die Endpunkte um, die noch mit 501 Not Implemented antworten. Im V-Modell bleibt die Reihe dafür auf der Ebene der Implementierung; die Web-Schicht wird im nächsten Artikel mit Integrationstests geprüft.
Ausgangspunkt ist das Projekt aus Artikel 08. Das Ergebnis findest Du im Ordner src/09-rest-api-fehlerbehandlung des Repositorys.
Welcher Fehler bekommt welchen Statuscode?
Ein Statuscode sagt dem Client, wer etwas ändern muss und ob eine Wiederholung sinnvoll ist. Codes ab 500 bedeuten: Der Server hat ein Problem, der Client kann nichts dafür. Für einen zu hohen Kostenbeitrag ist das falsch, denn der Client hat eine Anfrage geschickt, die gegen eine Fachregel verstößt. Der Vertrag unterscheidet deshalb vier Fälle der Klasse 4xx:
| Status | Bedeutung | Beispiel im Projekt | Ausnahme im Code |
|---|---|---|---|
400 Bad Request |
Die Anfrage passt nicht zum Vertrag. | Uhrzeit „7 Uhr“, 12 freie Plätze, max=100 |
von Spring erzeugt (Bean Validation, Typumwandlung) |
404 Not Found |
Die angefragte Ressource gibt es nicht. | Angebot 99, Fahrer 99 | NichtGefundenException |
409 Conflict |
Die Anfrage passt nicht zum aktuellen Zustand. | Angebot ist voll, stornierte Buchung bestätigen | Unterklassen von ZustandskonfliktException |
422 Unprocessable Content |
Die Anfrage ist formal richtig, verletzt aber eine Fachregel. | 5,00 € ab Gütersloh, eigene Fahrt buchen | Unterklassen von FachregelVerletztException |
500 Internal Server Error |
Fehler im Server, den der Client nicht beheben kann. | Programmierfehler, Datenbank nicht erreichbar | alle übrigen Ausnahmen |
Die Grenze zwischen 409 und 422 ist nicht immer scharf. Als Faustregel gilt hier: Kann dieselbe Anfrage später gelingen, weil sich der Zustand geändert hat (ein Platz wird frei), ist es ein Konflikt (409). Ist die Anfrage an sich unzulässig (zu hoher Beitrag, eigene Fahrt), verletzt sie eine Fachregel (422). Seit RFC 9110 heißt 422 offiziell Unprocessable Content; in Spring ist das die Konstante HttpStatus.UNPROCESSABLE_CONTENT.
Die Ausnahmen aus Artikel 04 und 08 sind schon so aufgebaut, dass sich daraus der Statuscode ablesen lässt: Jede fachliche Ausnahme erbt von einer der drei Oberklassen. Das Klassendiagramm zeigt die Hierarchie mit der neuen Ausnahme AngebotNichtAenderbarException und die Zuordnung, die gleich die Klasse ProblemBehandlung übernimmt:
Problem Details nach RFC 9457
Ein Statuscode allein reicht dem Client selten: Ein Frontend möchte anzeigen, was falsch war. Damit nicht jede API ihr eigenes Fehlerformat erfindet, beschreibt RFC 9457 (Problem Details for HTTP APIs, Nachfolger von RFC 7807) ein JSON-Objekt mit dem Medientyp application/problem+json. Der Vertrag aus Artikel 01 verwendet es als Schema Problem:
| Feld | Bedeutung | Beispiel |
|---|---|---|
type |
URI, die die Art des Problems kennzeichnet; Clients unterscheiden Fehler über dieses Feld | https://mitfahrlabor.example/probleme/kostenbeitrag-zu-hoch |
title |
kurze, für Menschen lesbare Zusammenfassung der Art | Fachregel verletzt |
status |
der HTTP-Statuscode, noch einmal im Body | 422 |
detail |
Erklärung zu genau diesem Fall | Kostenbeitrag 5.0 Euro ist zu hoch, … |
instance |
URI dieses einen Vorkommens, hier der Pfad der Anfrage | /v1/angebote |
Alle Felder sind freiwillig, und weitere Felder sind erlaubt (Erweiterungen). Fehlt type, gilt about:blank: Das Problem ist dann nur durch den Statuscode beschrieben. Die URI unter type muss nicht aufrufbar sein; sie dient als eindeutiger Name. Das Projekt verwendet deshalb die Beispiel-Domain mitfahrlabor.example.
Spring bringt dafür die Klasse ProblemDetail mit. Gibt ein Controller oder eine Fehlerbehandlungsmethode ein ProblemDetail zurück, schreibt Spring es als application/problem+json, übernimmt status als HTTP-Statuscode und setzt instance auf den Pfad der Anfrage, falls er noch leer ist.
Eine zentrale Fehlerbehandlung mit @RestControllerAdvice
Jede Controller-Methode könnte ihre Ausnahmen selbst mit try/catch abfangen. Das wären bei elf Operationen elf Kopien derselben Logik. Spring bietet dafür eine zentrale Stelle: Eine Klasse mit @RestControllerAdvice gilt für alle Controller. Ihre Methoden mit @ExceptionHandler werden aufgerufen, wenn eine Controller-Methode eine passende Ausnahme wirft. Welche Ausnahme eine Methode behandelt, steht im Typ ihres Parameters:
@RestControllerAdvice
public class ProblemBehandlung extends ResponseEntityExceptionHandler {
static final String TYP_BASIS = "https://mitfahrlabor.example/probleme/";
@ExceptionHandler
ProblemDetail nichtGefunden(NichtGefundenException e) {
return problem(HttpStatus.NOT_FOUND, "Nicht gefunden", e);
}
@ExceptionHandler
ProblemDetail zustandskonflikt(ZustandskonfliktException e) {
return problem(HttpStatus.CONFLICT, "Zustandskonflikt", e);
}
@ExceptionHandler
ProblemDetail fachregelVerletzt(FachregelVerletztException e) {
return problem(HttpStatus.UNPROCESSABLE_CONTENT, "Fachregel verletzt", e);
}
/** type aus dem Klassennamen: KostenbeitragZuHochException → .../probleme/kostenbeitrag-zu-hoch. */
private static ProblemDetail problem(HttpStatus status, String titel, MitfahrlaborException e) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, e.getMessage());
problem.setTitle(titel);
problem.setType(URI.create(TYP_BASIS + typName(e)));
return problem;
}
static String typName(MitfahrlaborException e) {
String name = e.getClass().getSimpleName().replace("Exception", "");
return name.replaceAll("([a-z])([A-Z])", "$1-$2").toLowerCase();
}
}Drei Methoden genügen für alle acht fachlichen Ausnahmen, denn eine Methode für FachregelVerletztException fängt auch jede Unterklasse. Gibt es für eine Ausnahme mehrere passende Methoden, wählt Spring die mit dem speziellsten Typ. Der title beschreibt die Gruppe, der type die genaue Art: Er entsteht aus dem Klassennamen, aus KostenbeitragZuHochException wird kostenbeitrag-zu-hoch. Ein Frontend kann so auf einzelne Fehler reagieren, ohne den deutschen Text in detail auszuwerten. Die Meldungen der Ausnahmen kommen unverändert in detail; sie enthalten keine internen Informationen wie Klassennamen, SQL oder Stacktraces.
Der Ablauf bei einem zu hohen Kostenbeitrag: Die Kostenregel wirft die Ausnahme, sie läuft durch Service und Controller zurück (die Transaktion wird dabei zurückgerollt), und der DispatcherServlet von Spring sucht eine passende @ExceptionHandler-Methode:
Die Antwort sieht jetzt so aus:
HTTP/1.1 422
Content-Type: application/problem+json
{"detail":"Kostenbeitrag 5.0 Euro ist zu hoch, erlaubt sind höchstens 1.86 Euro je Person.","instance":"/v1/angebote","status":422,"title":"Fachregel verletzt","type":"https://mitfahrlabor.example/probleme/kostenbeitrag-zu-hoch"}
Auch die Leseendpunkte nutzen jetzt die NichtGefundenException. Statt .orElse(ResponseEntity.notFound().build()) mit leerem Body steht dort:
@Override
public ResponseEntity<Angebot> holeAngebot(Long angebotId) {
return angebotService.finde(angebotId)
.map(AngebotMapper::zuApi)
.map(ResponseEntity::ok)
.orElseThrow(() -> new NichtGefundenException("Angebot", angebotId));
}HTTP/1.1 404
Content-Type: application/problem+json
{"detail":"Angebot 99 existiert nicht.","instance":"/v1/angebote/99","status":404,"title":"Nicht gefunden","type":"https://mitfahrlabor.example/probleme/nicht-gefunden"}
Die Fehler von Spring: ResponseEntityExceptionHandler
Die ProblemBehandlung erbt von ResponseEntityExceptionHandler. Diese Klasse von Spring MVC enthält schon Behandlungsmethoden für die eingebauten Fehler: falscher Body, falscher Parametertyp, unbekannter Pfad, falsche HTTP-Methode und einige mehr. Durch das Erben antworten auch sie im Format von RFC 9457:
GET /v1/angebote?datum=19.10.2026
HTTP/1.1 400
Content-Type: application/problem+json
{"detail":"Failed to convert 'datum' with value: '19.10.2026'","instance":"/v1/angebote","status":400,"title":"Bad Request"}
DELETE /v1/angebote/1
HTTP/1.1 405
Allow: PATCH, GET
Content-Type: application/problem+json
{"detail":"Method 'DELETE' is not supported.","instance":"/v1/angebote/1","status":405,"title":"Method Not Allowed"}
Ohne eigene Klasse ließe sich dasselbe in Spring Boot mit der Eigenschaft spring.mvc.problemdetails.enabled=true einschalten; die fachlichen Ausnahmen bräuchten dann trotzdem eine eigene @RestControllerAdvice. Eine einzige Klasse für beides ist übersichtlicher.
Vertragsverstöße im Body: 400 mit Feldliste
Verletzt der Body den Vertrag, wirft Spring eine MethodArgumentNotValidException. Die Standardantwort meldet nur "detail":"Invalid request content.". Für ein Formular im Frontend ist es hilfreicher zu erfahren, welche Felder falsch sind. Dafür überschreibt die ProblemBehandlung die zuständige Methode der Oberklasse und hängt eine Erweiterung fehler an:
/** Vertrag verletzt (Bean Validation am Request-Body): betroffene Felder als Erweiterung „fehler“. */
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException e,
HttpHeaders headers, HttpStatusCode status, WebRequest request) {
ProblemDetail problem = e.getBody();
problem.setDetail("Die Anfrage passt nicht zum Vertrag.");
List<String> fehler = e.getBindingResult().getFieldErrors().stream()
.map(f -> f.getField() + ": " + f.getDefaultMessage())
.sorted()
.toList();
problem.setProperty("fehler", fehler);
return handleExceptionInternal(e, problem, headers, status, request);
}Mit setProperty hinzugefügte Werte schreibt Spring als zusätzliche Felder auf oberster Ebene des JSON-Objekts. Das Angebot mit „7 Uhr“, 12 Plätzen und ohne Datum aus Artikel 03 ergibt jetzt:
HTTP/1.1 400
Content-Type: application/problem+json
{"detail":"Die Anfrage passt nicht zum Vertrag.","instance":"/v1/angebote","status":400,"title":"Bad Request","fehler":["abfahrt: must match \"^([01][0-9]|2[0-3]):[0-5][0-9]$\"","datum: must not be null","freiePlaetze: must be less than or equal to 8"]}
Die Texte der Feldfehler kommen aus Bean Validation und sind englisch, weil die Anwendung keine deutschen Meldungen hinterlegt.
Vertragsverstöße in Query-Parametern: eine 500, die keine sein sollte
Beim Ausprobieren des Flurmonitors (siehe unten) fiel ein Fall auf, den keiner der bisherigen Tests abgedeckt hat. Der Vertrag erlaubt für max die Werte 1 bis 50. Mit max=100 kam trotz ProblemBehandlung eine 500:
GET /v1/monitor/eintraege?max=100
HTTP/1.1 500
{"timestamp":"2026-11-10T00:45:05.476Z","status":500,"error":"Internal Server Error","path":"/v1/monitor/eintraege"}
Im Log steht der Grund:
jakarta.validation.ConstraintViolationException: listeMonitorEintraege.max: must be less than or equal to 50
Der Generator setzt @Validated an die erzeugte Schnittstelle MonitorApi und @Min(1) @Max(50) an den Parameter. @Validated an einer Klasse oder Schnittstelle schaltet die Methodenvalidierung über einen Proxy ein, ähnlich wie @Transactional aus Artikel 08. Dieser Proxy prüft die Parameter vor dem Aufruf und wirft eine ConstraintViolationException aus Jakarta Validation. Das ist keine Ausnahme von Spring MVC; der ResponseEntityExceptionHandler kennt sie nicht. Eine weitere Methode schließt die Lücke:
@ExceptionHandler
ProblemDetail parameterUngueltig(ConstraintViolationException e) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST,
"Die Anfrage passt nicht zum Vertrag.");
problem.setProperty("fehler", e.getConstraintViolations().stream()
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
.sorted()
.toList());
return problem;
}HTTP/1.1 400
Content-Type: application/problem+json
{"detail":"Die Anfrage passt nicht zum Vertrag.","instance":"/v1/monitor/eintraege","status":400,"title":"Bad Request","fehler":["listeMonitorEintraege.max: must be less than or equal to 50"]}
Was bleibt als 500? Alles, was wirklich ein Fehler des Servers ist. Dafür bleibt es bei der Standardantwort von Spring Boot mit Statuscode, Zeitpunkt und Pfad. Sie verrät nichts über den Aufbau der Anwendung; Details stehen nur im Log.
Mapper-Klassen statt zuApi
Bisher hatte jeder Controller eine private Methode zuApi, die eine Entity in ein Modell des Vertrags umwandelt. Mit den neuen Endpunkten kommen weitere Umwandlungen dazu: AngebotAenderung in eine Eingabe des Service, Angebot in einen MonitorEintrag. Der Controller würde damit zur Hälfte aus Umwandlungscode bestehen. Diese Aufgabe übernehmen jetzt eigene Klassen im Paket web, sogenannte Mapper:
/** Übersetzt zwischen den Modellen des API-Vertrags und der Entity bzw. den Service-Eingaben. */
final class AngebotMapper {
private AngebotMapper() {
}
static Angebot zuApi(de.dbh.mitfahrlabor.domain.Angebot a) {
return new Angebot(a.getFahrer().getId(), a.getHaltepunkt().getId(),
Richtung.valueOf(a.getRichtung().name()), a.getDatum(), a.getAbfahrt().toString(),
a.getFreiePlaetze(), a.getKostenbeitragEur().doubleValue(),
a.getId(), AngebotStatus.valueOf(a.getStatus().name()));
}
static NeuesAngebot zuNeuemAngebot(AngebotNeu neu) {
return new NeuesAngebot(neu.getFahrerId(), neu.getHaltepunktId(), zuFahrtrichtung(neu.getRichtung()),
neu.getDatum(), LocalTime.parse(neu.getAbfahrt()), neu.getFreiePlaetze(),
BigDecimal.valueOf(neu.getKostenbeitragEur()));
}
static Angebotsaenderung zuAenderung(AngebotAenderung a) {
return new Angebotsaenderung(
a.getAbfahrt() == null ? null : LocalTime.parse(a.getAbfahrt()),
a.getFreiePlaetze(),
a.getKostenbeitragEur() == null ? null : BigDecimal.valueOf(a.getKostenbeitragEur()),
a.getStatus() == AngebotAenderung.StatusEnum.ABGESAGT);
}
// istLeer(...) und zuFahrtrichtung(...) siehe Projekt
}Die Modelle des Vertrags (api.model) sind die Data Transfer Objects (DTO) der Anwendung: Sie beschreiben, was über die Leitung geht, und können sich mit dem Vertrag ändern. Die Entities beschreiben, was gespeichert wird. Der Mapper ist die einzige Stelle, die beide kennt. Er ist final, hat einen privaten Konstruktor und nur statische Methoden, weil er keinen Zustand und keine Abhängigkeiten hat; Checkstyle verlangt das mit der Regel HideUtilityClassConstructor aus Artikel 05. Ohne public ist er nur im Paket web sichtbar. Weil er eine reine Java-Klasse ist, lässt er sich ohne Spring mit einem Komponententest prüfen (AngebotMapperTest im Projekt).
Neben dem AngebotMapper gibt es BuchungMapper, HaltepunktMapper und MonitorMapper. Größere Projekte erzeugen solche Mapper oft mit einer Bibliothek wie MapStruct aus Annotationen. Bei vier kleinen Klassen ist handgeschriebener Code leichter zu lesen und zu debuggen.
Die restlichen Endpunkte
Angebote ändern und absagen
PATCH /angebote/{angebotId} ändert Abfahrt, freie Plätze oder Kostenbeitrag und sagt ein Angebot mit {"status": "abgesagt"} ab. Laut Vertrag ändert ein PATCH nur die Felder, die im Body stehen. Wie beim Buchungsstatus prüft die Entity selbst, ob die Änderung im aktuellen Zustand erlaubt ist:
/** Nur offene oder volle Angebote lassen sich noch ändern oder absagen. */
public boolean istAenderbar() {
return status == Angebotsstatus.OFFEN || status == Angebotsstatus.VOLL;
}
/** Sagt die Fahrt ab; das Angebot bleibt gespeichert, damit Mitfahrende den Hinweis sehen. */
public void sageAb() {
if (!istAenderbar()) {
throw new AngebotNichtAenderbarException(status);
}
status = Angebotsstatus.ABGESAGT;
}Die Methode aendere(neueAbfahrt, neueFreiePlaetze, neuerKostenbeitrag) übernimmt nur Werte ungleich null und setzt den Status passend zu den freien Plätzen auf VOLL oder OFFEN. Der Service lädt das Angebot, prüft einen neuen Kostenbeitrag mit der Kostenregel und ruft die Entity auf; wie in Artikel 08 schreibt Hibernate die Änderungen beim Commit:
@Transactional
public Angebot aendere(Long angebotId, Angebotsaenderung aenderung) {
Angebot angebot = angebotRepository.findById(angebotId)
.orElseThrow(() -> new NichtGefundenException("Angebot", angebotId));
if (aenderung.kostenbeitragEur() != null) {
Kostenregel.pruefe(aenderung.kostenbeitragEur(), angebot.getHaltepunkt().getEntfernungKm());
}
angebot.aendere(aenderung.abfahrt(), aenderung.freiePlaetze(), aenderung.kostenbeitragEur());
if (aenderung.absagen()) {
angebot.sageAb();
}
return angebot;
}Eine Lücke des Generators kennst Du aus Artikel 03: Das Schema AngebotAenderung verlangt minProperties: 1, geprüft wird das aber nicht. Ein leerer Body {} führte bisher zu 501, jetzt zu einem 200 ohne Änderung. Der Controller prüft das deshalb selbst. Statt einer eigenen Ausnahmeklasse nutzt er ErrorResponseException von Spring; sie trägt ihr ProblemDetail schon in sich, und der ResponseEntityExceptionHandler gibt es unverändert aus:
@Override
public ResponseEntity<Angebot> aendereAngebot(Long angebotId, AngebotAenderung aenderung) {
if (AngebotMapper.istLeer(aenderung)) {
// minProperties: 1 aus dem Vertrag prüft der Generator nicht, deshalb hier von Hand
throw new ErrorResponseException(HttpStatus.BAD_REQUEST, ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, "Die Änderung muss mindestens ein Feld enthalten."), null);
}
var geaendert = angebotService.aendere(angebotId, AngebotMapper.zuAenderung(aenderung));
return ResponseEntity.ok(AngebotMapper.zuApi(geaendert));
}Ein anderer Status als abgesagt (etwa storniert) scheitert schon beim Lesen des JSON, weil das erzeugte Enum nur diesen einen Wert kennt: 400 mit "detail":"Failed to read request".
Buchungen eines Angebots
GET /angebote/{angebotId}/buchungen nutzt die Abfragemethode findByAngebotIdOrderByAngefragtAm aus Artikel 07. Gibt es das Angebot nicht, soll laut Vertrag 404 kommen und nicht eine leere Liste. Der Service prüft das vorher mit existsById:
public List<Buchung> listeZuAngebot(Long angebotId) {
if (!angebotRepository.existsById(angebotId)) {
throw new NichtGefundenException("Angebot", angebotId);
}
return buchungRepository.findByAngebotIdOrderByAngefragtAm(angebotId);
}Nebenbei speichert der BuchungService den Zeitpunkt einer Anfrage jetzt auf Sekunden gekürzt (LocalDateTime.now(uhr).truncatedTo(ChronoUnit.SECONDS)). Vorher enthielt die Antwort beim Anlegen Nanosekunden, beim späteren Lesen aus H2 nur Mikrosekunden.
Der Flurmonitor
Die Flurmonitore der Schule zeigen die nächsten offenen Angebote, ohne Namen oder Pseudonyme. GET /monitor/eintraege hat vier freiwillige Parameter: haltepunkt, richtung, datum und max (Standard 10). Ohne datum zeigt der Monitor die Angebote ab heute. „Heute“ liefert die Clock aus Artikel 08, damit der Test eine feste Uhr einsetzen kann:
public List<Angebot> naechsteAngebote(LocalDate datum, Fahrtrichtung richtung, Long haltepunktId, int max) {
LocalDate ab = datum != null ? datum : LocalDate.now(uhr);
return angebotRepository.sucheFuerMonitor(ab, datum, richtung, haltepunktId, Limit.of(max));
}Die Abfrage im AngebotRepository kombiniert die freiwilligen Filter aus Artikel 07 mit einer Untergrenze für das Datum. Die Obergrenze max wird als Parameter vom Typ Limit übergeben; Spring Data hängt sie als fetch first ? rows only an das SQL an:
@Query("""
select a from Angebot a join fetch a.haltepunkt
where a.status = de.dbh.mitfahrlabor.domain.Angebotsstatus.OFFEN
and a.datum >= :ab
and (:datum is null or a.datum = :datum)
and (:richtung is null or a.richtung = :richtung)
and (:haltepunktId is null or a.haltepunkt.id = :haltepunktId)
order by a.datum, a.abfahrt
""")
List<Angebot> sucheFuerMonitor(@Param("ab") LocalDate ab, @Param("datum") LocalDate datum,
@Param("richtung") Fahrtrichtung richtung, @Param("haltepunktId") Long haltepunktId, Limit limit);Wozu dient join fetch a.haltepunkt? Der MonitorMapper liest getOrt() und getName() des Haltepunkts. Der Haltepunkt ist im Angebot als FetchType.LAZY verknüpft (Artikel 06): Hibernate lädt zunächst nur einen Platzhalter und holt die Daten erst beim ersten Zugriff. Der Mapper läuft aber im Controller, also nach dem Ende der Transaktion des Service, und open-in-view ist ausgeschaltet. Ohne join fetch endet der Aufruf deshalb mit einer 500:
org.hibernate.LazyInitializationException: Could not initialize proxy [de.dbh.mitfahrlabor.domain.Haltepunkt#1] - no session
Mit join fetch lädt dieselbe Abfrage die Haltepunkte gleich mit, in einem einzigen SQL-Befehl. Beim AngebotMapper tritt das Problem nicht auf: getHaltepunkt().getId() kennt der Platzhalter schon, dafür muss Hibernate nichts nachladen.
Die Startdaten aus data.sql liegen im Oktober 2026. Ohne Datum zeigt der Monitor deshalb erst etwas, wenn ein neues Angebot angelegt wurde, etwa Angebot 8 für den 19.11.2026 aus angebote.http:
GET /v1/monitor/eintraege
HTTP/1.1 200
Content-Type: application/json
[{"art":"Angebot","ort":"Gütersloh","treffpunkt":"Hauptbahnhof, ZOB Steig 3","richtung":"zur-schule","datum":"2026-11-19","zeit":"07:05","plaetze":2}]
Ausprobieren
Starte die Anwendung wie gewohnt:
.\gradlew.bat bootRunDie Dateien angebote.http, buchungen.http und die neue monitor.http im Projekt enthalten alle Anfragen; sie bauen in dieser Reihenfolge aufeinander auf (Angebot 8, Buchung 6). Die wichtigsten Fehlerfälle mit ihren Antworten:
| Anfrage | Status | type bzw. Inhalt |
|---|---|---|
POST /v1/angebote mit fahrerId 99 |
404 |
…/nicht-gefunden, „Nutzer 99 existiert nicht.“ |
POST /v1/angebote mit Lukas (ohne Führerschein) |
422 |
…/fuehrerschein-fehlt |
PATCH /v1/angebote/8 mit {"kostenbeitragEur": 2.50} |
422 |
…/kostenbeitrag-zu-hoch |
PATCH /v1/angebote/8 mit {} |
400 |
„Die Änderung muss mindestens ein Feld enthalten.“ |
PATCH /v1/angebote/2 nach dem Absagen |
409 |
…/angebot-nicht-aenderbar |
POST /v1/angebote/3/buchungen (Angebot voll) |
409 |
…/angebot-nicht-buchbar |
PATCH /v1/buchungen/6 storniert → bestaetigt |
409 |
…/statuswechsel-unzulaessig |
POST /v1/angebote/8/buchungen mit Ben (eigene Fahrt) |
422 |
…/buchung-unzulaessig |
POST /v1/angebote/8/buchungen mit Lina (17, ohne Einwilligung) |
422 |
…/einwilligung-fehlt |
GET /v1/angebote/99/buchungen |
404 |
…/nicht-gefunden |
GET /v1/monitor/eintraege?max=100 |
400 |
fehler: listeMonitorEintraege.max: … |
Damit beantwortet keine Operation des Vertrags mehr eine Anfrage mit 501 Not Implemented.
Tests und Abdeckungs-Gate
Mit den neuen Klassen ließ der Build zunächst das Abdeckungs-Gate aus Artikel 08 scheitern, obwohl alle Tests grün waren:
> Task :jacocoTestCoverageVerification FAILED
[ant:jacocoReport] Rule violated for bundle mitfahrlabor: instructions covered ratio is 0.66, but expected minimum is 0.70
[ant:jacocoReport] Rule violated for package de.dbh.mitfahrlabor.service: branches covered ratio is 0.86, but expected minimum is 0.90
Genau dafür ist das Gate da: Es meldet, dass neuer Code ohne Tests hinzugekommen ist. Die Grenzen werden nicht gesenkt; stattdessen kamen Komponententests dazu, die ohne Spring auskommen:
AngebotTest(domain):aendereübernimmt nur gesetzte Werte, 0 Plätze machen ein Angebot voll, ein abgesagtes Angebot lässt sich nicht mehr ändern;AngebotServiceTest,BuchungServiceTest(service):aenderemit und ohne Beitrag, unbekanntes Angebot,listeZuAngebot;MonitorServiceTest(service): Mit einer festen Uhr um 23:30 Uhr UTC ist in Deutschland schon der nächste Tag, der Monitor fragt also ab dem 17.11. statt ab dem 16.11.;AngebotRepositoryTest(repository,@DataJpaTest):sucheFuerMonitormit Untergrenze,Limitund Filtern;AngebotMapperTestundProblemBehandlungTest(web): Umwandlungen und dertypeaus dem Klassennamen.
.\gradlew.bat checkDanach laufen 86 Tests grün; die Abdeckung liegt bei 76 % der Anweisungen (Gate 70 %) und 89 % der Zweige. Die Controller selbst und das Zusammenspiel von Controller, ProblemBehandlung und JSON prüft noch kein Test. Das ist die Aufgabe des Integrationstests der Web-Schicht im nächsten Artikel.
Nächster Artikel
Die API ist vollständig, aber ihre Web-Schicht ist nur von Hand mit .http-Dateien geprüft. Im nächsten Artikel geht es im V-Modell eine Ebene nach oben zum Integrationstest der Web-Schicht: Mit @WebMvcTest startet nur der Web-Teil von Spring, MockMvcTester schickt Anfragen ohne echten Server, und @MockitoBean ersetzt die Services. So lässt sich jeder Fehlerfall dieses Artikels mit Statuscode und Problem Details automatisch prüfen.
Leitfragen
- Woran erkennst Du, ob ein Fehler mit
400,404,409,422oder500beantwortet wird?
400: Die Anfrage passt nicht zum Vertrag (Format, Pflichtfeld, Wertebereich). 404: Die angefragte Ressource oder eine referenzierte ID gibt es nicht. 409: Die Anfrage ist zulässig, passt aber nicht zum aktuellen Zustand und kann später gelingen. 422: Die Anfrage ist formal richtig, verletzt aber eine Fachregel. 500: Fehler im Server, den der Client nicht beheben kann. siehe Abschnitt „Welcher Fehler bekommt welchen Statuscode?“
- Welche Felder hat ein Problem-Details-Objekt nach RFC 9457, und welches davon wertet ein Client aus?
type (URI der Problemart), title (Kurzbeschreibung der Art), status (HTTP-Statuscode), detail (Erklärung dieses Falls) und instance (URI dieses Vorkommens), dazu beliebige Erweiterungen wie fehler. Ein Client unterscheidet Fehler über type (und status); title und detail sind für Menschen gedacht. Der Medientyp ist application/problem+json. siehe Abschnitt „Problem Details nach RFC 9457“
- Wie arbeiten
@RestControllerAdviceund@ExceptionHandlerzusammen, und warum genügen drei Methoden für acht fachliche Ausnahmen?
Eine Klasse mit @RestControllerAdvice gilt für alle Controller. Wirft eine Controller-Methode eine Ausnahme, sucht Spring dort eine @ExceptionHandler-Methode, deren Parametertyp passt, und schreibt ihren Rückgabewert (hier ein ProblemDetail) als Antwort. Eine Methode für eine Oberklasse wie FachregelVerletztException behandelt auch alle Unterklassen; passen mehrere Methoden, gewinnt der speziellste Typ. siehe Abschnitt „Eine zentrale Fehlerbehandlung mit @RestControllerAdvice“
- Wozu erbt die
ProblemBehandlungvonResponseEntityExceptionHandler, und warum braucht sie trotzdem eine Methode fürConstraintViolationException?
Die Oberklasse übersetzt die eingebauten Fehler von Spring MVC (falscher Body, falscher Parametertyp, unbekannter Pfad, falsche Methode …) in Problem Details; einzelne davon lassen sich überschreiben, etwa handleMethodArgumentNotValid für die Feldliste. Die ConstraintViolationException stammt aber aus der Methodenvalidierung, die @Validated an der erzeugten Schnittstelle über einen Proxy einschaltet; sie gehört nicht zu Spring MVC und endete deshalb als 500. siehe Abschnitt „Vertragsverstöße in Query-Parametern“
- Warum gehört die Umwandlung zwischen Entities und Vertragsmodellen in eigene Mapper-Klassen?
Entities beschreiben die gespeicherten Daten, die Modelle des Vertrags (DTO) das, was über HTTP geht; beide ändern sich aus unterschiedlichen Gründen. Der Mapper ist die einzige Stelle, die beide kennt. Der Controller bleibt kurz, und die Umwandlung lässt sich als reine Java-Klasse ohne Spring testen. siehe Abschnitt „Mapper-Klassen statt zuApi“
- Warum endet der Flurmonitor ohne
join fetchmit einerLazyInitializationException?
Der Haltepunkt ist LAZY verknüpft; Hibernate lädt zunächst nur einen Platzhalter. Der Mapper liest Ort und Name erst im Controller, also nach dem Ende der Transaktion, und open-in-view ist aus. Der Platzhalter kann dann nicht mehr nachladen. join fetch lädt die Haltepunkte in derselben Abfrage mit. siehe Abschnitt „Der Flurmonitor“
- Warum ließ das Abdeckungs-Gate den Build scheitern, und warum ist es falsch, die Grenze zu senken?
Neue Klassen (Mapper, Controller-Methoden, ProblemBehandlung, Service-Methoden) kamen ohne Tests hinzu; dadurch sank die Anweisungsabdeckung auf 66 % und die Zweigabdeckung im Paket service auf 86 %. Das Gate macht genau diese Lücke sichtbar. Wer die Grenze senkt, akzeptiert ungetesteten Code; richtig ist, die fehlenden Tests zu schreiben. siehe Abschnitt „Tests und Abdeckungs-Gate“
Links und weitere Informationen
zum Weiterlernen: Übungsaufgaben zu REST-API und Fehlerbehandlung
als Grundlage: REST-Grundlagen, Anforderungen und API-Vertrag
als Nachschlagewerk: RFC 9457 – Problem Details for HTTP APIs, RFC 9110 – HTTP Semantics, Statuscodes, Spring Framework – Error Responses, Spring Framework – Exceptions (@ExceptionHandler), Spring Data JPA – Limit
Hinweis zur Nachnutzung als Open Educational Resource (OER)
Dieser Artikel und seine Texte, Bilder, Grafiken, Code und sonstiger Inhalt sind - sofern nicht anders angegeben - lizenziert unter CC BY 4.0. Nennung gemäß TULLU-Regel bitte wie folgt: “Fehler mit Format: restliche Endpunkte, Mapper und Problem Details nach RFC 9457” von oer-informatik.de (H. Stein), Lizenz: CC BY 4.0. Der Artikel wurde unter https://oer-informatik.de/spring-boot-rest-api-fehlerbehandlung veröffentlicht, die Quelltexte sind in weiterverarbeitbarer Form verfügbar im Repository unter https://gitlab.com/oer-informatik/java-springboot/Backend. Erstellt mit KI-Assistenz; von mir umfassend redaktionell überarbeitet, geprüft und freigegeben. Stand: 11.11.2026.
[Kommentare zum Artikel lesen, schreiben] / [Artikel teilen] / [gitlab-Issue zum Artikel schreiben]
