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

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

REST-Controller schreiben

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.

Die Endpunkte

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

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

DTOs als Vertrag

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.

ModelMapper für den Normalfall

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 maßgeschneidertes DTO über zwei Aggregate

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.

Dedizierte Exceptions auf HTTP-Status abbilden

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

400 gegen 422: gebundene gegen geprüfte Eingabe

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.

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.

Verifiziert, nicht 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.

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