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.

Wie sieht ein sauberer Satz von Spring-MVC-Controllern aus? An einer kleinen Domäne mit zwei Aggregaten werden die sechs relevantesten HTTP-Verben gezeigt, dazu schlanke Controller ohne Fachlogik, DTOs als “Contract” mit ModelMapper, ein client-spezifisches DTO und dedizierte Exceptions, die zentral auf HTTP-Statuscodes abgebildet werden, inklusive der Unterscheidung von 400 und 422.

Beispielcode-Repository
https://gitlab.com/archi-lab/public/coding/spring-good-practices/-/tree/main/rest-controllers

REST-Controller schreiben

Dieses Modul zeigt an der Loyalty-Program-Domäne (zwei Aggregates, Customer und TicketPurchase, verbunden nur über eine typisierte ID), wie ein sauberer Satz von Spring-MVC-Controllern aussieht:

  • alle sieben HTTP-Verben
  • schlanke Controller ohne Fachlogik
  • DTOs als schlichte Records, die in beide Richtungen genutzt werden
  • ein client-spezifisches DTO über zwei Aggregate hinweg und
  • Fach-Exceptions, die ihren HTTP-Status selbst mitbringen.

Das Modul ist lauffähig. mvn spring-boot:run -pl rest-controllers startet die App auf http://localhost:8080 mit einer In-Memory-H2-Datenbank, die der DataSeeder mit ein paar Kunden und Käufen füllt, sodass ein erstes GET /customers sofort etwas zurückgibt.

Diese Seite ist das ausgearbeitete, lauffähige Gegenstück zur konzeptionellen Einführung Entwicklung von REST-APIs mit Spring Boot, die HTTP-Grundlagen, REST-Prinzipien und den Umgang mit Statuscodes erklärt.

Die beiden DTO-Prinzipien vorweg

Bevor es um die Controller geht, zwei Entscheidungen, die dieses Modul überall trägt. Sie sind der Ausgangspunkt, von dem aus wir arbeiten, nicht ein Gesetz: Wer versteht, warum sie so getroffen sind, kann in einer Lage, in der sie nicht mehr passen, bewusst davon abweichen.

Erstens: Ein DTO ist ein schlichter Record aus einfachen Basistypen. UUID, String, boolean, int, LocalDate, mehr steckt nicht darin. Kein Verhalten, keine Validierung, keine Annotationen: nur die Felder, die über die Leitung gehen. Ein Record ist unveränderlich, in einer Zeile geschrieben und sagt auf einen Blick, woraus der API-Contract besteht.

Zweitens: Ein Aggregat hat ein DTO, und das wird in beide Richtungen genutzt, so weit es geht. Eine Anfrage lässt die Felder leer, die der Server vergibt (die id, ein abgeleitetes Flag), eine Antwort trägt sie. Es ist derselbe Contract, einmal hin und einmal zurück. Ein zweites DTO entsteht erst dort, wo der Contract wirklich ein anderer ist. Ein Beispiel dafür ist der aggregatsübergreifende Abholbeleg weiter unten.

Wo diese Prinzipien an ihre Grenze kommen, zeigt das Modul offen: Das TicketPurchase-Aggregat ist bewusst auf dem anderen Weg gebaut, mit einem veränderbaren Bean-DTO und einer Mapping-Bibliothek. So stehen beide Ansätze im Code nebeneinander, und Sie können sie am selben Programm vergleichen.

Die Endpoints

Jedes der sieben Verben hat hier einen natürlichen Platz. Die Aufteilung auf zwei Aggregate ist kein Zufall (sie wurde gewählt, um das maßgeschneiderte DTO über mehr als ein Aggregat hinweg sinnvoll demonstrieren zu können).

Verb Endpoint Was er zeigt Status
GET /customers Liste lesen 200
GET /customers/{id} Einzelressource lesen 200 / 404
GET /ticketPurchases?collected=false Filtern über Query-String 200
POST /customers Anlegen 201 / 422
PUT /customers/{id} vollständiges Ersetzen 200 / 422
PATCH /customers/{id} teilweises Ändern 200
DELETE /customers/{id} Löschen 204
POST /ticketPurchases aggregatsübergreifend anlegen 201 / 404
POST /ticketPurchases/{id}/collection Aktion als Unterressource 200 / 409
GET /customers/{id}/pickupSlips maßgeschneidertes DTO über zwei Aggregate 200

Controller sind schlanke Komponenten

Ein Controller bindet die Anfrage, delegiert an einen Application-Service, bildet das Ergebnis auf ein DTO ab und setzt den Status. Mehr nicht: keine Fachlogik, kein Repository-Zugriff, kein try/catch und keine Validierung, denn was als gültige Eingabe zählt, entscheidet das Aggregat (→ CustomerController.java):

@PostMapping
public ResponseEntity<CustomerDto> create( @RequestBody CustomerDto request ) {
    Customer created = customerService.create( request.email(), request.name() );
    CustomerDto dto = toDto( created );
    return ResponseEntity.created( URI.create( "/customers/" + dto.id() ) ).body( dto );
}

private CustomerDto toDto( Customer customer ) {
    return new CustomerDto( customer.getId().getId(), customer.getEmail(), customer.getName() );
}

Die Entscheidungen liegen im CustomerService bzw. TicketPurchaseService: das Laden, die “nicht gefunden”-Entscheidung, die aggregatsübergreifende Prüfung, die Lebenszyklus-Regel. So bleibt der Controller eine reine Übersetzungsschicht zwischen HTTP und Domäne.

Auch der Unterschied zwischen PUT und PATCH ist eine Service-Entscheidung. PUT ersetzt alle änderbaren Felder, PATCH ändert nur die tatsächlich übergebenen: ein null im PATCH-Body heißt “dieses Feld unverändert lassen”, nicht “auf null setzen”.

public Customer patch( CustomerId id, String emailOrNull, String nameOrNull ) {
    Customer customer = findById( id );
    if ( emailOrNull != null ) customer.changeEmail( emailOrNull );
    if ( nameOrNull != null ) customer.rename( nameOrNull );
    return customerRepository.save( customer );
}

Wir prüfen hier bewusst explizit auf null, statt das DTO blind auf das Aggregat zu mappen. Das Aggregat hat keine Setter, sondern nur fachliche Methoden (changeEmail, rename), und genau das ist erwünscht: externe Eingaben laufen kontrolliert in die Domäne, nicht direkt hinein. Dass ein und dasselbe CustomerDto für POST, PUT und PATCH reicht, liegt genau daran: Ob ein Feld verpflichtend ist, steckt nicht im Transportobjekt, sondern darin, wie der Service das Aggregat ansteuert.

Das DTO füllen: von Hand oder mit ModelMapper

Die Abbildung zwischen Aggregat und DTO gehört in den Application-Layer, damit das Entity nie nach außen sichtbar wird. Wie man sie schreibt, ist eine Stilfrage, und das Modul zeigt beide Wege am selben Programm: das Customer-Aggregat baut sein Record-DTO von Hand, das TicketPurchase-Aggregat lässt ein Bean-DTO von ModelMapper füllen.

Weg 1: Record von Hand bauen (der Normalfall)

CustomerDto ist ein record, und der Controller setzt es mit einem Konstruktoraufruf zusammen (die toDto-Methode oben). Kein Setup, keine Bibliothek (→ CustomerDto.java):

public record CustomerDto( UUID id, String email, String name ) {}

PRO: Die Abbildung steht explizit da. Ein Blick auf new CustomerDto( ... ) zeigt genau, welches Feld woher kommt, es ist debugbar, und das Ergebnis ist unveränderlich.

CONTRA: Jedes Feld wird von Hand aufgeführt. Bei einem breiten DTO mit vielen gleichnamigen Feldern ist das mehr Tipparbeit als eine automatische 1:1-Kopie.

Weg 2: Bean-DTO mit ModelMapper (die Alternative)

TicketPurchaseDto ist ein veränderbares @Data-Bean mit Settern, und der Controller füllt es mit modelMapper.map( purchase, TicketPurchaseDto.class ). Gleichnamige Felder bildet ModelMapper von allein ab; erklärt werden muss nur die typisierte ID, weil das Entity einen Wrapper (TicketPurchaseId) führt, das DTO aber die flache UUID darin (→ MapperConfig.java):

modelMapper.typeMap( TicketPurchase.class, TicketPurchaseDto.class ).addMappings( mapper -> {
    mapper.map( source -> source.getId().getId(), TicketPurchaseDto::setId );
    mapper.map( source -> source.getBuyerId().getId(), TicketPurchaseDto::setBuyerId );
} );

PRO: Gleichnamige Felder kopiert der Mapper selbst; man schreibt nur die Abweichungen auf. Bei vielen trivialen Feldern spart das die stumpfe 1:1-Kopie.

CONTRA: Eine Bibliothek und eine Konfigurationsklasse mehr, und die Abbildung läuft über Reflection und Namenskonvention zur Laufzeit, nicht vom Compiler geprüft. Das DTO muss dafür ein veränderbares Bean mit Settern sein, kann also kein Record sein.

Beide Wege sind gültig, und beide halten die Abbildung im Application-Layer. Greifen Sie zuerst zum Record von Hand: Er braucht kein Setup und passt zur unveränderlichen, schlichten DTO-Idee von oben. ModelMapper lohnt sich, wenn wirklich viele gleichnamige Felder 1:1 zu kopieren wären und ein veränderbares Bean-DTO ohnehin in Kauf genommen wird.

Ein maßgeschneidertes DTO über zwei Aggregate

Ein Abholschalter möchte auf einem Beleg Name und E-Mail des Käufers (aus Customer) zusammen mit Code, Nummer, Datum und Abholbereitschaft des Tickets (aus TicketPurchase) sehen. Dieses PickupSlipDto entspricht keinem einzelnen Aggregat, also kann kein einzelner modelMapper.map(entity, ...)-Aufruf es erzeugen, und es ist auch kein Fall von “ein DTO in beide Richtungen”: Es ist ein eigener, dritter Vertrag. Es wird als Record von Hand zusammengesetzt (→ PickupSlipAssembler.java):

public List<PickupSlipDto> forCustomer( CustomerId customerId ) {
    Customer customer = customerService.findById( customerId );
    return ticketPurchaseService.findByBuyer( customerId ).stream()
            .map( purchase -> new PickupSlipDto( customer.getName(), customer.getEmail(),
                    purchase.getTransactionCode(), purchase.getPickupNumber(),
                    purchase.getPurchaseDate(), purchase.isReadyForPickup() ) )
            .toList();
}

Das ist der Grund, warum ein zweites DTO hier gerechtfertigt ist, und der Normalfall (ein DTO pro Aggregat, in beide Richtungen) es nicht ist: Der Beleg ist eine echte, andere Sicht, kein zweiter Zuschnitt desselben Vertrags. Weil diese Sicht zu keinem einzelnen Aggregat gehört, bekommt sie kein eigenes Package (das würde die Regel brechen, dass jedes Package auf ein Aggregat abbildet). Sie wird im customer-Aggregat beheimatet, dessen Ressource (/customers/{id}/pickupSlips) sie treibt, und greift auf das andere Aggregat über den TicketPurchaseService zu, nie auf dessen Repository.

Validierung gehört in die Domäne

Da das DTO ein schlichter Container ist, trägt es keine Constraints. Das Aggregat schützt seine eigenen Invarianten: Customer weist einen leeren Namen oder eine fehlerhafte E-Mail ab, TicketPurchase eine nicht-positive Abholnummer, jeweils mit einem Untertyp von LoyaltyProgramException. Die Web-Schicht hat keine Möglichkeit, ein Aggregat in einen ungültigen Zustand zu bringen.

public void changeEmail( String newEmail ) {
    if ( newEmail == null || newEmail.isBlank() || !newEmail.contains( "@" ) ) {
        throw new InvalidCustomerException( "Email must be a non-blank, well-formed address: " + newEmail );
    }
    this.email = newEmail;
}

Genau deshalb kann ein einziges geteiltes DTO Anlegen, Ersetzen und Patchen zugleich bedienen: die Regeln, die sich zwischen ihnen unterscheiden (für PUT alles verpflichtend, für PATCH alles optional), sind keine Annotationen am Transportobjekt, sondern stecken darin, wie der Service das Aggregat ansteuert.

Das Kleingedruckte: Bean Validation am Rand

Viele Spring-APIs weisen fehlerhafte Formen stattdessen am Rand ab, mit Bean Validation. Das schlägt früh fehl, noch bevor die Domäne läuft, und ist rein deklarativ. Der Preis: das DTO ist dann kein reiner Container mehr (und in aller Regel auch kein Record mehr, sondern ein annotiertes Bean), und ein einziges DTO braucht Validierungsgruppen, um beim Anlegen streng, beim PATCH aber nachsichtig zu sein:

@Data
public class CustomerDto {
    private UUID id;
    @NotBlank(groups = Full.class) @Email(groups = {Full.class, Patch.class})
    private String email;
    @NotBlank(groups = Full.class)
    private String name;

    public interface Full {}    // POST / PUT: jedes Feld verpflichtend
    public interface Patch {}    // PATCH: nur das Format prüfen, was vorhanden ist
}
@PostMapping
public ResponseEntity<CustomerDto> create( @Validated(CustomerDto.Full.class) @RequestBody CustomerDto request ) { ... }

@PatchMapping( "/{id}" )
public CustomerDto patch( @PathVariable UUID id, @Validated(CustomerDto.Patch.class) @RequestBody CustomerDto request ) { ... }

Eine Verletzung ist hier eine MethodArgumentNotValidException, die Spring standardmäßig auf 400 abbildet. Wer diese Rand-Fehler als 422 möchte, passend zu den Fach-Fehlern, braucht genau das eine Stück zentrale Behandlung, das dieses Modul sonst vermeidet: eine kleine @RestControllerAdvice (oder eine Unterklasse von ResponseEntityExceptionHandler), die den Status umsetzt. Die Domäne muss in jedem Fall validieren, sonst vertraut sie wieder ihren Aufrufern. Rand-Validierung ist also eine Optimierung, kein Ersatz.

Fach-Exceptions bringen ihren HTTP-Status selbst mit

Fachfehler werfen eine eigene Exception (Untertyp von LoyaltyProgramException), nie eine nackte RuntimeException. Jede Exception erklärt ihre HTTP-Bedeutung selbst mit @ResponseStatus, sodass die Controller frei von try/catch bleiben und es keine zentrale Stelle gibt, die man synchron halten muss. Der Standard wird einmal an der Basisklasse festgelegt, ein Untertyp überschreibt ihn nur, wenn er etwas Engeres bedeutet:

@ResponseStatus( HttpStatus.UNPROCESSABLE_ENTITY )   // der Standard für jede verletzte Invariante
public abstract class LoyaltyProgramException extends RuntimeException { ... }

@ResponseStatus( HttpStatus.NOT_FOUND )
public class CustomerNotFoundException extends LoyaltyProgramException { ... }
Auslöser Exception Status
ID existiert nicht CustomerNotFoundException / TicketPurchaseNotFoundException 404
ungültiger Inhalt (leerer Name, falsche E-Mail, nicht-positive Nummer) InvalidCustomerException / InvalidTicketPurchaseException 422
verweist auf unbekannten Kunden UnknownCustomerException 404
Zustand passt nicht (Ticket schon abgeholt) TicketAlreadyCollectedException 409
Body unlesbar (kaputtes JSON, falscher Feldtyp) (von Spring) 400

Der Response Body ist dann der “Standard-Error” Response Body von Spring Boot; server.error.include-message=always hält die Begründung darin. Der Preis dafür, dass die zentrale Behandlung entfällt: der Status sitzt jetzt an jeder Exception, statt an einer Stelle gebündelt. Die naheliegende Ablösung des Standardkörpers in der Produktion ist ProblemDetail (RFC 7807).

400 gegen 404/409/422: ließ es sich bauen, oder ließ es sich ausführen?

Das ist der interessante Teil. Die Grenze ist eine Zeile:

  • 400 heißt, Spring konnte aus dem Body nicht einmal ein gültiges Request-Objekt bauen. Ursache kann ein “kaputtes” JSON-Dokument sein oder ein alphanumerischer String, wo eigentlich eine Zahl erwartet wird. In dem Fall kann der Controller gar nicht erst “sinnvoll starten”.
  • 404, 409 und 422 bedeuten, das das Request-Objekt gebaut werden konnte - aber die Domäne hat es danach abgewiesen, weil Invarianten oder Business Rules verletzt sind.

Eine leere E-Mail-Adresse zum Beispiel wäre eher ein 422, kein 400: der Body wurde sauber geparst, der Inhalt ist es, den die Domäne verweigert. Der unbekannte Kunde beim “aggregatsübergreifenden” Anlegen liegt noch einmal anders: Der Body ist sauber, aber er verweist auf einen Kunden, den es gar nicht gibt. Das ist ein 404, kein 422: nicht der Inhalt ist ungültig, sondern die referenzierte Ressource fehlt. Es braucht dafür einen expliziten aggregatsübergreifenden Blick, den keine Annotation leisten kann (→ TicketPurchaseService.java):

public TicketPurchase create( CustomerId buyerId, String transactionCode, int pickupNumber, LocalDate purchaseDate ) {
    if ( !customerRepository.existsById( buyerId ) ) {
        throw new UnknownCustomerException( buyerId );   // -> 404, nicht 400: referenzierter Kunde existiert nicht
    }
    return ticketPurchaseRepository.save(
            new TicketPurchase( buyerId, transactionCode, pickupNumber, purchaseDate ) );
}

Ob ein fehlender Verweis ein 404 (fehlende Ressource) oder ein 422 (nicht verarbeitbarer Inhalt) ist, ist eine Auslegungsfrage, denn die adressierte Ressource /ticketPurchases existiert ja. Wir behandeln ihn hier als 404, weil ein Verweis auf eine nicht existierende Ressource am ehesten “nicht gefunden” bedeutet und die Grenze zu den echten Inhaltsfehlern (422) so schärfer wird.

Kurz gefasst: 400 heißt “ich konnte das nicht als Anfrage lesen”, alles andere heißt “ich habe es gelesen, und hier ist der Grund, warum ich es nicht ausführe”. Die Linie verläuft genau dort, wo Springs Bindung endet und der eigene Code beginnt.

Aktion als Unterressource statt Status-Feld

POST /ticketPurchases/{id}/collection stößt das Abholen an. Der Lebenszyklus wird nicht über ein vom Client gesetztes Status-Feld gesteuert, sondern als Aktion auf einer Unterressource gepostet; ob der Übergang erlaubt ist, entscheidet der Server. Ein zweiter Aufruf auf dasselbe, schon abgeholte Ticket ist ein Konflikt und ergibt 409. Dasselbe Muster (Aktion als geposteter Unter-Ressource, kein roher Status-Wert) liegt dem REST-Design der ST2-Milestones M3/M4 zugrunde.

Getestet, nicht nur behauptet

Jede Zeile der Statuscode-Tabelle ist durch einen Test belegt (→ LoyaltyRestApiTest.java): er fährt die Controller über MockMvc durch denselben Web-Stack, den auch die laufende App nutzt. In ST2-M4 ist das genau die Form eines *IT-Integrationstests unter Failsafe.

Wie man solche MockMvc-Tests aufbaut und die langsameren Integrationstests (*IT, Failsafe) von den schnellen Unit-Tests (*Test, Surefire) trennt, ist ein Thema für sich. Das eigene Modul REST-Controller mit MockMvc testen zeigt es an einem winzigen Beispiel, mit beiden Zuschnitten nebeneinander: dem vollen Durchstich mit echtem Service und der isolierten Web-Schicht mit @WebMvcTest. Der Code dazu liegt im Repository testing-good-practices.

Datumsformat

Die JSON-Schnittstelle nutzt für Datumsangaben ISO-8601 (2027-07-20), den Austauschstandard für REST. Das ist eine bewusste Ausnahme von der sonst deutschen Datumsschreibweise des Hauses: über die Leitung gehört das maschinenlesbare ISO-Format, die deutsche Schreibweise gehört in die Anzeige.

Jenseits dieses Moduls

Bewusst nicht enthalten, um den Fokus auf den Controller-Mechaniken zu halten: Paginierung und Sortierung großer Listen, Content-Negotiation, HATEOAS/Hypermedia-Links und ProblemDetail (RFC 7807) als standardisierter Fehlerkörper. ProblemDetail ist in der Produktion die naheliegende Ablösung des hier verwendeten Standard-Fehlerkörpers von Spring Boot.