Zum Hauptinhalt springen

AB 03 – Die Antwort selbst entwerfen

Lernziele

Nach Bearbeitung dieses Arbeitsblatts kannst du:

  • begründen, warum eine JPA-Entität kein geeignetes Antwortformat ist
  • ein DTO als record entwerfen und benennen, was hinein gehört und was nicht
  • einen Mapper schreiben, der zwischen Entität und DTO übersetzt
  • erklären, warum Eingabe und Ausgabe unterschiedliche DTOs brauchen
  • eine abgeleitete Abfrage mit zwei Bedingungen im Namen schreiben
  • zwei Endpunkte für eine untergeordnete Ressource bauen

Die Situation

Aus der letzten Sitzung ist ein Satz hängengeblieben:

„Die Antwort ist zwanzigtausend Zeichen lang und der Name des Lieferanten kommt nicht darin vor."

Der Absturz dahinter wäre reparabel gewesen — zwei Annotationen, und Jackson hätte den Zirkel klaglos durchbrochen. Das eigentliche Problem, nämlich die enge Kopplung von Datenbank und Schnittstelle, bleibt. Das Objekt, das der Controller herausgibt, ist dieselbe Entität, die auch die Datenbank abbildet — ein und dasselbe Objekt für zwei verschiedene Zwecke. Der Controller reicht einfach die Entität durch, so wie sie aus der Datenbank kommt. Ändert sich die Entität, ändert sich die Antwort mit, und der Clientcode bricht. Diese enge Kopplung zwischen den Schichten ist aufzulösen. Dafür ist festzulegen, welche Daten an den Client ausgeliefert werden sollen. Das geschieht mittels DTOs.

Entität und DTO sind zwei verschiedene Dinge

Suppliergebaut für die DATENBANKidnamecontactarticlesGegenrichtungjedes Feld ist da,weil Hibernate es braucht —nicht, weil ein Clientes sehen soll.MapperSupplierDtogebaut für den CLIENTidnamecontactarticleCountnur die Zahljedes Feld ist da,weil jemand es braucht.Kein Feld zeigt zurück —die Kette endet hier.

DTO heißt Data Transfer Object: ein Objekt, das nur dazu da ist, Daten von hier nach dort zu bringen. Es hat keine Fachlichkeit, keine Datenbankanbindung und keine Beziehungen — nur Felder. Die Regeln dahinter stehen im Infoblatt DTOs und Schichten.

Drei Dinge gewinnst du damit auf einen Schlag:

Datenbank und Schnittstelle sind entkoppeltEin zusätzliches Feld in der Entität ändert die Antwort nicht mehr von selbst — und umgekehrt
Die Antwort endetKein Feld zeigt zurück, also gibt es keinen Kreis
Du entscheidest, was sichtbar istEin Feld, das nicht im DTO steht, verlässt den Server nicht — auch nicht versehentlich

Aufgaben

Aufgabe 1 – Die Ausgabe-DTOs

DTOs werden mit Hilfe von Records umgesetzt. Diese kennst du aus dem Tutorial zur Personenverwaltung. In einer Zeile deklarierst du den Datentyp und formulierst den Konstruktor: die nötigen Zugriffsmethoden für die Attribute, equals(), hashCode() und toString() liefern Records automatisch mit.

src/main/java/de/szut/webshop/contact/ContactDto.java
public record ContactDto(
String street,
String postcode,
String city,
String phone) {
}
src/main/java/de/szut/webshop/supplier/SupplierDto.java
public record SupplierDto(
Long id,
String name,
ContactDto contact,
long articleCount) {
}

Zwei Entscheidungen stecken darin, über die man reden muss.

Die Anschrift wandert mit, die Artikel nicht. Das ist kein Zufall: Der Contact gehört zum Lieferanten — ohne ihn wäre die Auskunft unvollständig. Dazu kommt der Use-Case: Wer den Lieferanten anzeigt, will seine Adresse in der Regel gleich mitsehen — sonst braucht es zwei Aufrufe für eine einzige Anzeige. Die Artikel dagegen sind eine eigene Liste, die beliebig lang werden kann und nicht bei jeder Anzeige gebraucht wird. Wer sie braucht, ruft ihren eigenen Endpunkt auf.

articleCount statt der Liste. Eine Zahl beantwortet die häufigste Frage („hat der Lieferant überhaupt Artikel?"), ohne dass die Artikel selbst übertragen werden müssen.

ContactDto hat keine id. Die Anschrift ist nur als Teil des Lieferanten ansprechbar; es gibt keinen Endpunkt /contacts/3. Eine Kennung, die niemand benutzen kann, gehört nicht in die Antwort.

src/main/java/de/szut/webshop/article/ArticleDto.java
public record ArticleDto(
Long id,
String designation,
BigDecimal price,
Long supplierId,
String supplierName) {
}

Hier ist die Stelle, an der der Kreis endet. Vom Lieferanten wandern zwei Felder mit, nicht der Lieferant. Ein ArticleDto enthält keinen SupplierDto, der wieder Artikel enthielte.

Auch das ist eine Use-Case-Entscheidung. Wer eine Artikelliste anzeigt, will in der Regel wissen, von wem der Artikel stammt — als Name, nicht als bloße Zahl. Ohne supplierName bräuchte der Client einen zweiten Aufruf pro Artikel, nur um den Namen nachzuschlagen. supplierId bleibt trotzdem dabei: Wer mehr braucht als den Namen — Kontakt, Artikelzahl —, ruft /suppliers/{id} auf.

Was ist mit createdAt und updatedAt?

Sie stehen in der Entität, aber nicht im DTO. Frage dich bei jedem Feld: Wer draußen braucht das? Für den Katalog niemand — sie sind Buchhaltung der Anwendung. Sollte die Einkaufsabteilung später fragen, wann ein Preis zuletzt geändert wurde, kommt updatedAt dazu. Dann aber bewusst.

Die Faustregel für jeden Endpunkt

Vier Entscheidungen, ein Muster: Nicht fragen, was die Datenbank hat — fragen, was der Client braucht. Erst der Use-Case. Was will der Client mit der Antwort tun — eine Liste anzeigen, ein Formular vorbefüllen, eine Entscheidung treffen? Dann der Datenbedarf. Welche Felder braucht er dafür wirklich? Erst daraus die Form des DTOs. Genau diese Felder, nicht die, die die Entität zufällig mitbringt.

Bei SupplierDto sah das so aus: Der Client will wissen, mit wem er es zu tun hat — name, contact — und ob es sich lohnt, nach Artikeln zu fragen — articleCount. Nicht, was die Tabelle supplier an Spalten hat. Dieselbe Frage stellst du dir bei jedem Endpunkt, den du ab jetzt baust.

Aufgabe 2 – Die Eingabe-DTOs

Für das Anlegen brauchst du andere Felder als für die Ausgabe. Eine id schickt der Client nicht mit — die vergibt die Datenbank. Eine articleCount auch nicht.

src/main/java/de/szut/webshop/contact/CreateContactDto.java
public record CreateContactDto(
String street,
String postcode,
String city,
String phone) {
}
src/main/java/de/szut/webshop/supplier/CreateSupplierDto.java
public record CreateSupplierDto(
String name,
CreateContactDto contact) {
}
Warum steckt hier ein CreateContactDto statt vier einzelner Felder?

Naheliegend wäre, street, postcode, city und phone direkt auf CreateSupplierDto zu packen — eine Ebene weniger für den Client. Zwei Gründe sprechen dagegen.

Der Client bekommt die Anschrift beim Lesen (GET /suppliers/1) als verschachteltes contact-Objekt zurück. Sähe das Schreiben anders aus, bräuchte er zwei Vorstellungen von „ein Lieferant" — eine zum Senden, eine zum Empfangen. Dieselbe Form für beide Richtungen ist vorhersagbarer. Dazu kommt Arbeitsblatt 04: Mit @Valid auf dem contact-Feld lassen sich die Prüfregeln für eine Anschrift einmal in CreateContactDto definieren, statt sie bei jedem DTO mit Adresse einzeln zu wiederholen.

Das ist auch keine Eigenheit dieses Projekts: Adressen und ähnliche zusammengesetzte Werte stehen in so gut wie jeder öffentlichen API als eigenes verschachteltes Objekt, nicht als Felder mit Präfix.

src/main/java/de/szut/webshop/article/CreateArticleDto.java
public record CreateArticleDto(
String designation,
BigDecimal price) {
}
Zwei DTOs für dieselbe Sache — ist das nicht doppelt?

ContactDto und CreateContactDto sehen im Moment gleich aus. Trotzdem sind es zwei: Sie beschreiben verschiedene Richtungen, und die entwickeln sich auseinander.

Beim CreateArticleDto ist es schon jetzt sichtbar: Es hat keinen Lieferanten, weil der in der Adresse steht — POST /api/v1/suppliers/7/articles. In Arbeitsblatt 04 kommen an den Eingabe-DTOs außerdem Prüfregeln dazu, die in einer Antwort nichts zu suchen haben.

Wer beides in eine Klasse legt, muss später bei jedem Feld entscheiden, für welche Richtung es gilt. Das ist die teurere Variante. Ganz ohne eigene Klasse — die Prüfregeln direkt auf der Entity — wäre es die teuerste: Dieselbe Klasse trüge dann @Column, @JoinColumn und die Prüfregeln aus Arbeitsblatt 04 nebeneinander, für zwei verschiedene Anliegen.

Aufgabe 3 – Der Mapper

Hereinkommende DTOs müssen in Entitäten und in der Gegenrichtung Entitäten in DTOs umgewandelt werden. Irgendwo muss diese Übersetzung stattfinden. Nicht im Controller — der hat mit HTTP zu tun. Nicht in der Entität — die weiß nichts von der Schnittstelle. Also in einer eigenen Klasse.

Der Artikel-Mapper ist als Vorlage schon fertig:

src/main/java/de/szut/webshop/article/ArticleMapper.java
@Component
public class ArticleMapper {

public ArticleDto toDto(Article article) {
Supplier supplier = article.getSupplier();
return new ArticleDto(
article.getId(),
article.getDesignation(),
article.getPrice(),
supplier.getId(),
supplier.getName());
}

public Article toEntity(CreateArticleDto dto, Supplier supplier) {
Article article = new Article();
article.setDesignation(dto.designation());
article.setPrice(dto.price());
article.setSupplier(supplier);
return article;
}
}

Zwei Hinweise, bevor du anfängst:

  • Die Artikelzahl kann der Mapper nicht selbst ermitteln — er hat kein Repository. Sie wird ihm übergeben.
  • toDto(Contact) muss mit null umgehen können: Ein Lieferant ohne Anschrift ist zwar unerwünscht, aber bis Arbeitsblatt 04 nicht verboten.
Wenn du nicht weiterkommst: der SupplierMapper
src/main/java/de/szut/webshop/supplier/SupplierMapper.java
@Component
public class SupplierMapper {

public SupplierDto toDto(Supplier supplier, long articleCount) {
return new SupplierDto(
supplier.getId(),
supplier.getName(),
toDto(supplier.getContact()),
articleCount);
}

private ContactDto toDto(Contact contact) {
if (contact == null) {
return null;
}
return new ContactDto(
contact.getStreet(),
contact.getPostcode(),
contact.getCity(),
contact.getPhone());
}

public Supplier toEntity(CreateSupplierDto dto) {
Supplier supplier = new Supplier();
supplier.setName(dto.name());
supplier.setContact(toEntity(dto.contact()));
return supplier;
}

private Contact toEntity(CreateContactDto dto) {
Contact contact = new Contact();
contact.setStreet(dto.street());
contact.setPostcode(dto.postcode());
contact.setCity(dto.city());
contact.setPhone(dto.phone());
return contact;
}
}
Der Mapper liegt bei der Sache, die er umwandelt

SupplierMapper liegt im Paket supplier — neben der Entität, den beiden DTOs und gleich auch neben dem Service. Wer die Antwort des Lieferanten ändert, findet alles Nötige nebeneinander.

Das ist der Gewinn der fachlichen Aufteilung: Diese fünf Dateien lägen sonst in vier verschiedenen Ordnern.

Aufgabe 4 – Die Abfrage für die Artikelzahl

Der Mapper braucht eine Zahl. Die soll aus der Datenbank kommen, ohne dass ein einziger Artikel geladen wird.

src/main/java/de/szut/webshop/article/ArticleRepository.java
public interface ArticleRepository extends JpaRepository<Article, Long> {

List<Article> findAllBySupplierIdOrderByIdDesc(Long supplierId);

long countBySupplierId(Long supplierId);
}

Das Verfahren kennst du aus dem Gästebuch: Spring liest den Namen wie einen Satz.

NamensteilBedeutung
count…statt der Zeilen nur ihre Anzahl — SELECT count(*)
findAllalle Treffer, nicht nur einen
…BySupplierIdWHERE supplier_id = ? — Spring liest SupplierId als supplier.id: erst das Feld supplier, darin das Feld id. Daraus wird die Fremdschlüsselspalte
…OrderByIdDescORDER BY id DESC — neueste zuerst

countBySupplierId erzeugt genau eine Zahl in der Datenbank. Kein Artikel wird geladen, kein Objekt gebaut — bei einem Lieferanten ist das kaum messbar.

Aufgabe 5 – Den Lieferanten-Controller umbauen

Wenn du nicht weiterkommst: der umgebaute Controller
src/main/java/de/szut/webshop/supplier/SupplierController.java
@RestController
@RequestMapping("/api/v1/suppliers")
public class SupplierController {

private final SupplierRepository supplierRepository;
private final ArticleRepository articleRepository;
private final SupplierMapper mapper;

public SupplierController(SupplierRepository supplierRepository,
ArticleRepository articleRepository,
SupplierMapper mapper) {
this.supplierRepository = supplierRepository;
this.articleRepository = articleRepository;
this.mapper = mapper;
}

@PostMapping
public ResponseEntity<SupplierDto> createSupplier(@RequestBody CreateSupplierDto dto) {
Supplier saved = supplierRepository.save(mapper.toEntity(dto));
SupplierDto response = mapper.toDto(saved, 0);

URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(response.id())
.toUri();

return ResponseEntity.created(location).body(response);
}

@GetMapping
public ResponseEntity<List<SupplierDto>> findAllSuppliers() {
return ResponseEntity.ok(supplierRepository.findAll().stream()
.map(supplier -> mapper.toDto(
supplier,
articleRepository.countBySupplierId(supplier.getId())))
.toList());
}

@GetMapping("/{id}")
public ResponseEntity<SupplierDto> findSupplierById(@PathVariable Long id) {
return supplierRepository.findById(id)
.map(supplier -> mapper.toDto(
supplier,
articleRepository.countBySupplierId(id)))
.map(ResponseEntity::ok)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "Kein Lieferant mit der Id " + id));
}
}

Das Ergebnis für den ersten Lieferanten müsste wie folgt aussehen:

[
{
"id": 1,
"name": "Nordmetall GmbH",
"contact": {
"street": "Hafenstr. 12",
"postcode": "28217",
"city": "Bremen",
"phone": "0421 123456"
},
"articleCount": 1
}
]

145 Zeichen statt 19 438. Der Name steht drin.

Es funktioniert — aber sieh dir an, was dabei im Hintergrund passiert

Der Endpunkt GET /api/v1/suppliers ruft countBySupplierId für jeden Lieferanten einzeln auf. Sieh dir das im Log an. Eine select-Anweisung für die Liste plus mindestens eine je Lieferant. Das Muster heißt N+1: ein Aufruf für die Sammlung, dann noch einmal einer pro Element.

Bei drei Lieferanten fällt das nicht auf. Bei dreihundert schon.

Warum es hier trotzdem so bleibt: Die Alternative wäre eine selbst geschriebene Abfrage, die Lieferanten und Anzahlen in einem Zug holt. Die ist schnell, aber sie ist nicht das Thema dieses Arbeitsblatts. Wichtig ist, dass du das Muster siehst und weißt, wonach du suchst. Mehr dazu im Infoblatt Beziehungen mit JPA abbilden.

Aufgabe 6 – Die beiden Artikel-Endpunkte

Artikel gehören zu einem Lieferanten. Das soll man der Adresse ansehen:

POST /api/v1/suppliers/1/articles einen Artikel bei Lieferant 1 anlegen
GET /api/v1/suppliers/1/articles das Sortiment von Lieferant 1

Drei Hinweise, bevor du anfängst:

  • Orientiere dich am Aufbau von SupplierController aus Aufgabe 5 — Konstruktor-Injection der Repositories und des Mappers, ResponseEntity.created(location)... beim Anlegen, ResponseEntity.ok(...) beim Abrufen. Der Aufbau ist derselbe, nur mit anderen Typen.
  • Für den POST eines Artikels brauchst du neben den Artikeldaten aus dem Rumpf des Requests den Lieferanten, den du anhand seiner als Pfadvariable mitgegebenen ID aus der Datenbank bekommst.
  • Die Pfadvariable {supplierId} steht diesmal in der @RequestMapping der ganzen Klasse, nicht nur bei einer einzelnen Methode wie bisher. Jede Methode, die sie braucht, nimmt sie trotzdem ganz normal über @PathVariable Long supplierId entgegen.
Wenn du nicht weiterkommst: der ArticleController
src/main/java/de/szut/webshop/article/ArticleController.java
@RestController
@RequestMapping("/api/v1/suppliers/{supplierId}/articles")
public class ArticleController {

private final ArticleRepository articleRepository;
private final SupplierRepository supplierRepository;
private final ArticleMapper mapper;

public ArticleController(ArticleRepository articleRepository,
SupplierRepository supplierRepository,
ArticleMapper mapper) {
this.articleRepository = articleRepository;
this.supplierRepository = supplierRepository;
this.mapper = mapper;
}

@PostMapping
public ResponseEntity<ArticleDto> createArticle(@PathVariable Long supplierId,
@RequestBody CreateArticleDto dto) {
Supplier supplier = supplierRepository.findById(supplierId).orElseThrow();
Article saved = articleRepository.save(mapper.toEntity(dto, supplier));
ArticleDto response = mapper.toDto(saved);

URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(response.id())
.toUri();

return ResponseEntity.created(location).body(response);
}

@GetMapping
public ResponseEntity<List<ArticleDto>> findArticlesOfSupplier(
@PathVariable Long supplierId) {
return ResponseEntity.ok(articleRepository.findAllBySupplierIdOrderByIdDesc(supplierId).stream()
.map(mapper::toDto)
.toList());
}
}
requests.http
### Artikel bei Lieferant 1 anlegen
POST http://localhost:8080/api/v1/suppliers/1/articles
Content-Type: application/json

{
"designation": "Mutter M6",
"price": 0.12
}

### Sortiment von Lieferant 1
GET http://localhost:8080/api/v1/suppliers/1/articles
Die Location-Kopfzeile zeigt vorerst ins Leere

Beim Anlegen eines Artikels entsteht als Adresse …/api/v1/suppliers/1/articles/5. Diesen Endpunkt gibt es nicht — es gibt keinen, der einen einzelnen Artikel liefert.

Das ist kein Versehen, sondern eine offene Baustelle: Sechs Endpunkte reichen, um die Architektur zu verstehen, und jeder weitere kostet Zeit ohne neue Einsicht. In Arbeitsblatt 04 steht er als Zusatzaufgabe — dort ist er in fünf Minuten gebaut.

Was passiert bei einem unbekannten Lieferanten?

Ruf einmal POST /api/v1/suppliers/999/articles auf. Das orElseThrow() im Controller wirft eine NoSuchElementException, und daraus wird eine 500 — als wäre der Server schuld.

Er ist es nicht: Der Client hat nach etwas gefragt, das es nicht gibt. Das ist eine 404. Diese Stelle bleibt bis Arbeitsblatt 04 so — dort bekommt sie eine ordentliche Antwort, zusammen mit allen anderen Fehlerfällen.

Aufgabe 7 – Testfälle

IDBeschreibungVorbedingungTestschritteErwartetes ErgebnisErgebnis
TF-01Der Endpunkt liefert wieder eine AntwortController auf DTO umgebaut, fetch = LAZY, ein Artikel vorhanden
  1. GET auf /api/v1/suppliers
Status 200 mit einer Antwort von rund 150 Zeichen.
TF-02Die Artikel stehen nicht in der AntwortTF-01 wurde ausgeführt
  1. Antwort durchsehen
Es gibt kein Feld articles, aber ein Feld articleCount mit dem Wert 1.
TF-03LAZY blieb unverändertTF-01 wurde ausgeführt
  1. In Supplier nachsehen
Dort steht weiterhin fetch = FetchType.LAZY — und trotzdem gibt es keinen Fehler mehr.
TF-04Artikel anlegenLieferant 1 existiert
  1. POST auf /api/v1/suppliers/1/articles mit designation und price
Status 201, eine Location-Kopfzeile, und im Rumpf stehen supplierId und supplierName — aber kein verschachtelter Lieferant.
TF-05Sortiment abrufen, neueste zuerstMindestens drei Artikel bei Lieferant 1
  1. GET auf /api/v1/suppliers/1/articles
Alle Artikel dieses Lieferanten, absteigend nach id sortiert.
TF-06Der Zähler stimmtTF-05 wurde ausgeführt
  1. GET auf /api/v1/suppliers/1
articleCount entspricht der Anzahl aus TF-05.
TF-07Bekannter offener Punkt: unbekannter LieferantEs gibt keinen Lieferanten 999
  1. POST auf /api/v1/suppliers/999/articles
Status 500. Das ist falsch und wird in Arbeitsblatt 04 zu einer 404.

Wo du jetzt stehst

Sechs Endpunkte, saubere Antworten. Aber sieh dir den SupplierController noch einmal an: Er holt aus zwei Repositories, ruft den Mapper auf, zählt Artikel und baut Adressen. Vom „entgegennehmen, weiterreichen, antworten" — der eigentlichen Aufgabe eines REST-Controllers — ist wenig übrig: lauter Fachlichkeit, ein klarer Kohäsionsverstoß.

Und einige Fragen sind gar nicht beantwortet: Was, wenn ein Name leer ist? Wenn ein Preis negativ ist? Wenn jemand einen Lieferanten löscht, der noch Artikel hat?

Keine davon hat mit HTTP zu tun. Sie brauchen einen eigenen Ort.