Kontakt
stefan.bente[at]th-koeln.de
+49 2261 8196 6367
Discord Server
Prof. Bente Personal Zoom
Adresse
Steinmüllerallee 4
51643 Gummersbach
Gebäude LC4
Raum 1708 (Wegbeschreibung)
Sprechstunde nach Vereinbarung
Terminanfrage: calendly.com Wenn Sie dieses Tool nicht nutzen wollen, schicken Sie eine Mail und ich weise Ihnen einen Termin zu.

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.

Beispielcode-Repository
https://gitlab.com/archi-lab/public/architecture-good-practices/-/tree/main/domain-exception-hierarchy
Keywords
Exception, Domain Exception, Exception-Hierarchie, Fehlerbehandlung, ResponseStatus, HTTP-Status, 404, 409, 422, REST, Vererbung

Eine Exception-Familie für die Domäne

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.

Eine abstrakte Parent-Exception, und drei Kategorien

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).

Dedizierte Exception, wenn ein Fehler es “verdient”

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.

Das Beispiel

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.

Das Kleingedruckte: Zentrale Zuordnung statt @ResponseStatus als weitergehende Option

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.