Wie sieht ein sauberer Satz von Spring-MVC-Controllern aus? An einer kleinen Domäne mit zwei Aggregaten werden alle sieben HTTP-Verben gezeigt, dazu schlanke Controller ohne Fachlogik, DTOs als Vertrag mit ModelMapper, ein client-spezifisches DTO über zwei Aggregate hinweg und dedizierte Exceptions, die zentral auf HTTP-Statuscodes abgebildet werden, inklusive der Unterscheidung von 400 und 422.
Dieses Modul zeigt an der Treueprogramm-Domäne (zwei Aggregate, 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 Vertrag mit ModelMapper, ein
client-spezifisches DTO über zwei Aggregate hinweg und dedizierte Exceptions, die zentral auf
HTTP-Statuscodes abgebildet werden.
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.
Jedes der sieben Verben hat hier einen natürlichen Platz. Die Aufteilung auf zwei Aggregate ist kein Zufall: erst sie macht das maßgeschneiderte DTO über mehr als ein Aggregat hinweg sinnvoll.
| Verb | Endpunkt | 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 + Location |
| PUT | /customers/{id} |
vollständiges Ersetzen | 200 |
| PATCH | /customers/{id} |
teilweises Ändern | 200 |
| DELETE | /customers/{id} |
Löschen | 204 |
| POST | /ticketPurchases |
aggregatsübergreifend anlegen | 201 / 422 |
| POST | /ticketPurchases/{id}/collection |
Aktion als Unterressource | 200 / 409 |
| GET | /customers/{id}/pickupSlips |
maßgeschneidertes DTO über zwei Aggregate | 200 |
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 (→ CustomerController.java):
@PostMapping
public ResponseEntity<CustomerDto> create( @Valid @RequestBody CustomerRequest request ) {
Customer created = customerService.create( request.email(), request.name() );
CustomerDto dto = modelMapper.map( created, CustomerDto.class );
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path( "/{id}" ).buildAndExpand( dto.getId() ).toUri();
return ResponseEntity.created( location ).body( dto );
}
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. (Wer auf ein veränderbares DTO
statt auf ein Aggregat mappt, kann alternativ ModelMapper mit setSkipNullEnabled(true) verwenden, das
übernimmt dann dieselbe “null heißt überspringen”-Semantik.)
Nach außen sichtbar ist nie das Entity, sondern das DTO. Eingehende und ausgehende DTOs sind getrennt:
ein Request-DTO trägt keine ID (die vergibt der Server), ein Response-DTO trägt sie. Eingangsseitig ist
das DTO ein record mit Bean-Validation-Annotationen
(→ CustomerRequest.java),
ausgangsseitig eine schlichte Klasse, die ModelMapper füllt.
Gleichnamige Felder bildet ModelMapper automatisch ab. Erklärt werden muss nur die typisierte ID, weil
das Entity einen Wrapper (CustomerId) führt, das DTO aber die flache UUID darin
(→ MapperConfig.java):
modelMapper.typeMap( Customer.class, CustomerDto.class ).addMappings( mapper ->
mapper.map( source -> source.getId().getId(), CustomerDto::setId ) );
Eine Zeile pro ID, alles andere geht von allein. Das ist der ganze Aufwand.
Ein Abholschalter möchte auf einem Beleg den Käufer (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. Es wird
von Hand zusammengesetzt
(→ PickupSlipAssembler.java):
public List<PickupSlipDto> forCustomer( CustomerId customerId ) {
Customer customer = customerService.findById( customerId );
return ticketPurchaseService.findByBuyer( customerId ).stream()
.map( purchase -> toSlip( customer, purchase ) )
.toList();
}
Das ist der bewusste Gegenpol zum trivialen ModelMapper-Fall: die einfache 1:1-Abbildung überlässt man dem Mapper, die client-spezifische, aggregatsübergreifende Sicht baut man explizit.
Fachfehler werfen eine eigene Exception (Untertyp von
LoyaltyProgramException),
nie eine nackte RuntimeException. Die Abbildung auf Statuscodes passiert an genau einer Stelle, im
RestExceptionHandler
(@RestControllerAdvice). Erst das hält die Controller frei von try/catch.
| Auslöser | Exception | Status |
|---|---|---|
| ID existiert nicht | CustomerNotFoundException / TicketPurchaseNotFoundException |
404 |
| Body unlesbar oder Bean-Validation verletzt | (von Spring) | 400 |
| Body gültig, verweist aber auf unbekannten Kunden | UnknownCustomerException |
422 |
| Zustand passt nicht (Ticket schon abgeholt) | TicketAlreadyCollectedException |
409 |
Das ist der lehrreiche Teil. Wenn Spring den Request-Body bindet, bekommt man 400 geschenkt: ist das JSON
kaputt oder verletzt es eine @Valid-Regel, kommt der Controller-Rumpf gar nicht erst zur Ausführung.
public record CreateTicketPurchaseRequest(
@NotNull UUID buyerId,
@NotBlank String transactionCode,
@Positive int pickupNumber,
@NotNull LocalDate purchaseDate
) {}
422 dagegen entsteht erst, wo man selbst prüft. Der Body oben kann syntaktisch tadellos sein und trotzdem auf einen Kunden verweisen, den es nicht gibt. Das kann keine Annotation wissen; es braucht einen expliziten aggregatsübergreifenden Blick im Service (→ TicketPurchaseService.java):
public TicketPurchase create( CustomerId buyerId, String transactionCode, int pickupNumber, LocalDate purchaseDate ) {
if ( !customerRepository.existsById( buyerId ) ) {
throw new UnknownCustomerException( buyerId ); // -> 422, nicht 400
}
return ticketPurchaseRepository.save(
new TicketPurchase( buyerId, transactionCode, pickupNumber, purchaseDate ) );
}
Kurz gefasst: 400 heißt “die Anfrage selbst ist falsch”, 422 heißt “die Anfrage ist wohlgeformt, aber so nicht ausführbar”. Die Grenze verläuft genau dort, wo die automatische Bindung endet und die fachliche Prüfung beginnt.
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.
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.
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.
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 schlanken ApiError.