Wenn eine fachliche Regel bricht, ist die Frage nicht, ob eine Exception fliegt, sondern welche. Anhand einer kleinen Buch-Ausleihe zeigt diese Seite eine Exception-Familie mit einem abstrakten, statuslosen Parent und drei Kategorien, die je einen HTTP-Status tragen (404, 409, 422). Eine dedizierte Exception wird erst dann eingeführt, wenn ein Fehler es “verdient”, also gezielt unterschieden werden soll, und erbt ihren Status aus ihrer Kategorie. Die Zuordnung von Status zu Fehler passiert im Domänencode, nicht im Controller.
Wenn eine fachliche Regel bricht, wirft der Code eine Exception. Die Frage ist nicht ob, sondern welche: eine einzige, generische Exception für alles verliert die Information, um welche Art von Fehler es sich handelt. Und genau diese Information ist es, die der Aufrufer, ein Test und die HTTP-Schicht (also die REST-Controller, die ein REST-API implementieren) brauchen.
(Das Mapping von Exceptions auf HTTP Error Codes ist in dieser Infopage erklärt. Es gibt auch ein in Beispiel-Repository mit einem kleinen Code-Beispiel dafür.)
Ein naheliegender erster Wurf ist eine gemeinsame Basis-Exception, die man direkt wirft und die einen Standard-Status trägt. Das Problem ist, dass es keinen passende “neutralen” HTTP-Standard-Error gibt.
422 Unprocessable Entity bezieht sich auf den Inhalt einer Anfrage,409 Conflict auf den Zustand des Ziels,404 Not Found auf eine fehlende Ressource.Keiner dieser Fälle ist allgemeiner als die anderen; einen davon zum Default zu erklären, ist willkürlich.
Deshalb ist der Parent der Exception-Familie, LendingException, abstract und trägt keinen Status. Sie
existiert nur, um die ganze Familie an einer Stelle zu fangen (catch, assertThrows), und kann selbst
nicht geworfen werden. Die eigentlichen Würfe ordnen sich einer von drei mitgelieferten Kategorien zu,
die je einen Status per @ResponseStatus tragen:
| Kategorie | Status | Bedeutung |
|---|---|---|
NotFoundException |
404 |
Eine adressierte Ressource existiert nicht. |
ConflictException |
409 |
Die Anfrage ist in Ordnung, aber der aktuelle Zustand verbietet sie. |
InvalidInputException |
422 |
Der Inhalt selbst wird abgelehnt (leer, fehlend, außerhalb des Bereichs). |
400 Bad Request gehört bewusst nicht in die Familie: Das ist Springs eigener Code dafür, dass es aus
dem Request-Body gar kein Objekt bauen konnte (kaputtes JSON, falscher Feldtyp). Die Trennlinie ist
“konnte nicht gelesen werden” (400) gegen “gelesen, aber fachlich abgelehnt” (alles andere).
Der Default ist, dass man die generische Exception des passenden Typs wirft (z.B. NotFoundException). Einen
Dedizierte Exception ist nur dann notwendig, wenn man den Fehler gezielt unterscheiden und behandeln will.
Im Beispiel ist das BookAlreadyOnLoanException: Die Regel “ein Buch ist zu einem Zeitpunkt höchstens einmal
verliehen” ist “interessant” genug, um sie zu benennen. Vielleicht will man in einem Web Client für die
Buch-Ausleihe eine spezielle Fehlermeldung genau dazu schreiben.
Diese dedizierte Exception leitet von ConflictException ab und erbt deren 409, braucht also keine
eigene Annotation:
public class BookAlreadyOnLoanException extends ConflictException {
public BookAlreadyOnLoanException( String message ) {
super( message );
}
}
Springs @ResponseStatus-Auflösung geht die Vererbungskette hoch: Eine geworfene
BookAlreadyOnLoanException ohne eigene Annotation landet auf der 409 ihrer Kategorie.
Eine kleines Softwaresystem für Buch-Ausleihe mit zwei Aggregates, Member und Book. Ein
Verleih-Vorgang wird nur durch die Referenz Book.borrowedBy auf ein Member dargestellt.
Es gibt zwei REST-Endpoints. An ihnen entstehen alle drei Fehler-Kategorien.
classDiagram
class LendingException {
<<abstract>>
}
LendingException <|-- NotFoundException
LendingException <|-- ConflictException
LendingException <|-- InvalidInputException
ConflictException <|-- BookAlreadyOnLoanException
| Endpoint | Fall | Exception | Status |
|---|---|---|---|
POST /members |
Name ist leer | InvalidInputException |
422 |
POST /loans |
Member oder Book unbekannt | NotFoundException |
404 |
POST /loans |
Buch ist schon verliehen | BookAlreadyOnLoanException |
409 |
Die Zuordnung passiert im Domänencode dort, wo die Regel lebt: der leere Name im Konstruktor von
Member, das schon verliehene
Buch in Book.lendTo. Der Controller
enthält keine eigene Logik. Inbesondere werden die Prüfungen für die HTTP-Errorcodes nicht im
Controller durchgeführt, sondern kommen aus den @ResponseStatus-Annotationen der Exceptions,
die in den Services und im Domain Layer geworfen
werden. LendingExceptionMappingTest
prüft alle drei Fälle über HTTP.
Statt jede Exception mit @ResponseStatus zu versehen, kann man die Zuordnung auch an einer Stelle
bündeln, in einem @RestControllerAdvice mit @ExceptionHandler-Methoden. Das lohnt sich, sobald die
Fehlerantwort mehr sein soll als ein Statuscode (ein einheitlicher Fehler-Body, Logging, Übersetzung).
Beide Wege sind gültig; dieses Beispiel bleibt bei @ResponseStatus, weil so Kategorie und Status an
einer Stelle stehen.