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