DTOs und Schichten
Die Frage, mit der alles anfängt
Du hast eine Entität. Sie ist mit @Entity annotiert, Hibernate legt eine Tabelle dafür an, das Repository liefert sie. Warum sie dann nicht einfach herausgeben?
@GetMapping("/{id}")
public Supplier findSupplierById(@PathVariable Long id) {
return repository.findById(id).orElseThrow();
}
Das funktioniert. Beim ersten Versuch. Danach kommen fünf Probleme, und jedes einzelne kostet mehr Zeit, als die zwei Klassen gekostet hätten, die sie verhindern.
| Die Antwort dreht sich im Kreis | Der Lieferant kennt seine Artikel, jeder Artikel kennt seinen Lieferanten. Was dabei herauskommt, kannst du unten im ausklappbaren Abschnitt „Der Kreis — einmal wirklich gesehen" lesen |
| Du verrätst zu viel | passwordHash, internalNote, deletedAt — alles, was in der Tabelle steht, steht in der Antwort |
| Der Client bricht, wenn du die Tabelle änderst | Ein umbenanntes Feld ist eine Änderung der Datenbank. Sie darf nicht bei fremden Programmen ankommen |
| Der Client darf Dinge setzen, die er nicht setzen darf | Schickt er beim Anlegen eine id mit, überschreibt er womöglich einen fremden Datensatz |
| Der Client braucht die Daten anders, als sie gespeichert sind | Eine Oberfläche zeigt eine Kundenkarte. Die Datenbank hat den Kunden auf drei Tabellen verteilt |
Die Lösung ist ein zweiter Satz Klassen — nicht als Bürokratie, sondern als Grenze: eine Klasse für das, was in der Datenbank steht, und eine für das, was über die Leitung geht.
Der Kreis — einmal wirklich gesehen
Wer den Fehler schon kennt, lässt den Kasten zu; für alles Weitere genügt der Satz: Ein DTO lässt diesen Kreis gar nicht erst entstehen.
Der Kreis — einmal wirklich gesehen
Von diesem Fehler liest man in jeder Anleitung einen Halbsatz. Ihn einmal gesehen zu haben, ist etwas anderes — vor allem, weil er sich nicht als Fehler meldet.
Die Ausgangslage sind zwei Entitäten, die aufeinander zeigen. Genau so steht es im Webshop-Projekt:
@Entity
public class Supplier {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "supplier", fetch = FetchType.LAZY)
private List<Article> articles = new ArrayList<>();
}
@Entity
public class Article {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String designation;
@ManyToOne
@JoinColumn(name = "supplier_id", nullable = false)
private Supplier supplier;
}
Beide Richtungen sind gewollt: Vom Lieferanten zu seinen Artikeln, vom Artikel zurück zum Lieferanten. In der Datenbank ist das eine Spalte — supplier_id in der Artikeltabelle. Im Arbeitsspeicher sind es zwei Verweise, die im Kreis zeigen.
Und jetzt der Endpunkt, den man beim ersten Mal genau so schreibt:
@GetMapping("/{id}")
public Supplier findSupplierById(@PathVariable Long id) {
return repository.findById(id).orElseThrow();
}
orElseThrow() denn überhaupt etwas?Ja — und zwar von selbst. Optional.orElseThrow() ohne Argument gibt es seit Java 10; ist nichts da, wirft es eine NoSuchElementException. Die ist ungeprüft, deshalb braucht die Signatur kein throws, und deshalb übersetzt der Code anstandslos.
Nur ist es die falsche Ausnahme: Eine unbekannte Kennung ergibt damit eine 500 samt Stacktrace im Protokoll — für einen Fall, in dem der Client nach etwas gefragt hat, das es nicht gibt. Das ist eine 404.
Richtig ist die Fassung mit Argument, und die steht auch im Referenzprojekt:
Supplier supplier = supplierRepository.findById(id)
.orElseThrow(() -> new SupplierNotFoundException(id));
Aus dieser fachlichen Ausnahme macht die Übersetzungsstelle eine saubere 404 — wie das geht, steht im Infoblatt Fehlerantworten.
Für den Kreis ändert das nichts: Die falsche Ausnahme greift nur, wenn der Lieferant nicht existiert. Findet er sich, läuft alles Weitere genau wie beschrieben. Drei Zeilen, zwei Fehler — und keiner von beiden fällt beim Übersetzen auf.
Was herauskommt
Ein Lieferant mit einem einzigen Artikel ergibt knapp 16 000 Zeichen: derselbe Artikel 166 mal, 500 Ebenen tief verschachtelt. Der Anfang der Antwort:
{"articles":[{"designation":"Wollpullover","id":41,"price":89.90,
"supplier":{"articles":[{"designation":"Wollpullover","id":41,"price":89.90,
"supplier":{"articles":[{"designation":"Wollpullover","id":41,"price":89.90,
"supplier":{"articles":[ …
Und das Ende:
… "supplier":{"articles":[]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}}]}
Der Serialisierer geht dem Verweis nach: Lieferant → Artikel → Lieferant → Artikel → … Er hört erst auf, als eine eingebaute Grenze greift — Jackson erlaubt höchstens 500 Ebenen Verschachtelung. Dann schließt er alle offenen Klammern und ist fertig.
Der Statuscode sagt 200 OK. Die Antwort ist syntaktisch gültiges JSON — jeder Parser nimmt sie an. Im Serverprotokoll steht nichts.
Der Client bekommt also keine Fehlermeldung, sondern eine Antwort, mit der er nichts anfangen kann. Wer nur auf den Statuscode schaut, sucht an der falschen Stelle — und zwar lange.
Mit älteren Jackson-Fassungen flog an dieser Stelle ein StackOverflowError und der Server antwortete mit 500. Das war lauter und deshalb ehrlicher gesagt: leichter zu finden.
Die Artikel sind mit FetchType.LAZY verknüpft — sie werden erst geladen, wenn jemand sie anfasst. In einem frisch erzeugten Spring-Boot-Projekt steht spring.jpa.open-in-view auf true. Die Datenbanksitzung bleibt dann offen, bis die Antwort geschrieben ist.
Damit ist es der Serialisierer selbst, der das Nachladen auslöst — und den Kreis in Gang setzt. Deshalb passiert im Test nichts und beim Aufruf über HTTP alles.
Die Reparaturen, die keine sind
| Versuch | Warum er nicht trägt |
|---|---|
@JsonIgnore auf articles | Das Feld ist damit überall weg — auch dort, wo man die Artikel gerade braucht |
@JsonManagedReference / @JsonBackReference | Funktioniert, aber die Entität trägt jetzt Wissen darüber, wie sie über HTTP aussieht. Zwei Aufgaben in einer Klasse |
fetch = FetchType.EAGER umstellen | Ändert nichts am Kreis — lädt nur noch mehr Daten in ihn hinein |
open-in-view=false setzen | Aus dem stillen Unsinn wird eine LazyInitializationException. Besser, aber immer noch kein Ergebnis, das der Client brauchen kann |
Was das DTO daran ändert
Die Kette endet, weil das DTO an ihrem Ende kein Objekt mehr trägt, das zurückzeigt:
public record ArticleDto(
Long id,
String designation,
BigDecimal price,
Long supplierId,
String supplierName) {
}
Statt des ganzen Lieferanten stehen dort seine Kennung und sein Name. Damit weiß der Client, zu wem der Artikel gehört, und kann trotzdem nicht im Kreis laufen: Eine Long verweist auf nichts.
Das ist die Regel hinter allen DTO-Entwürfen mit Beziehungen:
An der Grenze der Antwort steht ein Wert, kein Verweis. Wer mehr braucht, ruft den Endpunkt dafür auf.
Was ein DTO ist
DTO steht für Data Transfer Object: ein Objekt, dessen einzige Aufgabe es ist, Daten von A nach B zu tragen. Kein Verhalten, keine Regeln, keine Datenbank-Annotationen. In Java ist ein record dafür genau das richtige Werkzeug.
public record SupplierDto(
Long id,
String name,
ContactDto contact,
long articleCount) {
}
Ein record ist unveränderlich, hat automatisch Konstruktor, Getter, equals und toString — und passt in vier Zeilen. Genau deshalb sind DTOs keine Fleißarbeit.
Die Entität beantwortet die Frage: Wie speichern wir das? Das DTO beantwortet: Was zeigen wir davon?
Dass beide anfangs fast dieselben Felder haben, ist normal. Der Punkt ist, dass sie sich unabhängig voneinander ändern dürfen. Genau das ist die Leistung.
Zwei Richtungen, zwei DTOs
Ein einziges DTO für hin und zurück sieht sparsam aus und ist es nicht. Was hineingeht und was herauskommt, ist nicht dasselbe:
Die Regel dahinter lautet: Ein Feld, das der Client nicht setzen darf, hat im Eingangs-DTO nichts verloren. Nicht „wir ignorieren es dann halt" — es steht gar nicht erst da. Was nicht existiert, kann nicht missbraucht werden.
Deshalb heißen die beiden Klassen im Webshop-Tutorial CreateSupplierDto und SupplierDto. Das Präfix Create sagt: Das ist die Anfrage, nicht die Antwort.
Der Client bestimmt den Schnitt, nicht die Tabelle
Die vier Punkte oben sind Schutzargumente: Sie sagen, was schiefgeht, wenn man die Entität herausgibt. Der fünfte ist ein Entwurfsargument — und im Alltag der wichtigste.
Eine Schnittstelle wird für einen Zweck benutzt. Am anderen Ende sitzt ein Programm, das etwas vorhat: eine Kundenkarte anzeigen, eine Bestellung aufgeben, eine Liste durchblättern. Die Frage, die den Endpunkt entwirft, lautet deshalb nicht „welche Tabellen haben wir?", sondern:
Was will der Client tun — und welche Daten braucht er dafür in einem Stück?
Datenbanken sind auf Speichern hin entworfen: Redundanz vermeiden, Daten auf Tabellen verteilen, jede Sache genau einmal ablegen. Eine Oberfläche ist auf Anzeigen hin entworfen: alles beisammen, was zusammen auf den Bildschirm gehört. Das sind zwei verschiedene Ziele — und deshalb dürfen die Antworten der Schnittstelle nicht so geschnitten sein wie die Tabellen.
Ein Beispiel
Eine Anwendung zeigt eine Kundenkarte: Name, Anschrift, Telefonnummer. In der Datenbank liegt das auf drei Tabellen — der Kunde, seine Anschrift, seine Kontaktwege.
Wer die Tabellen eins zu eins nach außen reicht, zwingt den Client zu drei Aufrufen für eine Karte:
GET /api/v1/customers/42
GET /api/v1/customers/42/address
GET /api/v1/customers/42/contact
Drei Anfragen, drei Wartezeiten, drei Fehlerfälle, die der Client einzeln behandeln muss — und eine Oberfläche, die halb gefüllt dasteht, wenn die zweite Anfrage hängt. Für eine Liste von zwanzig Kunden werden daraus einundsechzig Aufrufe.
Ein Endpunkt, der nach dem Zweck geschnitten ist, liefert dasselbe in einem Stück:
GET /api/v1/customers/42
{
"id": 42,
"fullName": "Anna Meyer",
"address": { "street": "Am Deich 12", "postcode": "28199", "city": "Bremen" },
"phone": "0421 123456",
"email": "anna.meyer@example.de"
}
Beachte fullName: In der Datenbank stehen Vor- und Nachname getrennt — und das ist dort richtig, denn nur so lässt sich nach dem Nachnamen sortieren. Wenn aber jeder Client die beiden ohnehin sofort zusammensetzt, gehört das auf die Serverseite. Sonst schreiben fünf Clients dieselbe Zeile fünfmal — und der sechste macht es anders.
Die zwei Fehlerarten
| Was passiert | Woran man es merkt | |
|---|---|---|
| Zu wenig geliefert | Der Client muss nachfragen, um eine Ansicht zu füllen | Für eine Seite braucht es fünf Aufrufe. Man nennt das eine geschwätzige Schnittstelle |
| Zu viel geliefert | Jede Antwort schleppt Felder mit, die niemand ansieht | Die Liste holt zu jedem Lieferanten seine 400 Artikel, angezeigt wird eine Zahl |
Genau der zweite Fall steckt im Webshop-Tutorial:
public record SupplierDto(
Long id,
String name,
ContactDto contact,
long articleCount) {
}
articleCount statt List<ArticleDto> — weil die Übersicht eine Zahl zeigt. Wer die Artikel wirklich sehen will, ruft den Endpunkt dafür auf:
GET /api/v1/suppliers/7/articles
Eine Übersichtsliste und eine Detailansicht brauchen selten dasselbe. Es ist völlig in Ordnung, für eine Ressource zwei Ausgabe-DTOs zu haben — etwa SupplierSummaryDto mit drei Feldern für die Liste und SupplierDto mit allem für die Einzelansicht.
Das ist keine Verdopplung, sondern eine Entscheidung: Für jede Ansicht steht an genau einer Stelle geschrieben, was sie braucht.
Die Umkehrung wäre genauso falsch. Wenn jeder neue Knopf in der Oberfläche einen neuen Endpunkt bekommt, hängt die Schnittstelle an einem bestimmten Client — und der nächste, den es noch gar nicht gibt, passt nicht mehr.
Die Mitte: Der Zuschnitt folgt dem fachlichen Vorgang („eine Kundenkarte anzeigen", „eine Bestellung aufgeben"), nicht dem einzelnen Bildschirmelement. Ein Vorgang überlebt die Umgestaltung der Oberfläche.
Die Frage, die man sich vor jedem Endpunkt stellt
- Wer ruft das auf, und was hat er damit vor?
- Welche Felder braucht er dafür — und welche ganz sicher nicht?
- Muss er nach dieser Antwort noch einmal fragen, um seine Ansicht zu füllen? Wenn ja: Ist das der Regelfall oder die Ausnahme?
- Wird ein Feld bei jedem Client gleich umgerechnet oder zusammengesetzt? Dann gehört es fertig in die Antwort.
Diese vier Fragen beantwortet man bevor man das DTO schreibt. Das DTO ist das Ergebnis der Antworten — nicht eine Kopie der Entität, aus der man hinterher Felder streicht.
Die Schichten
DTOs sind nur die eine Hälfte. Die andere ist die Frage, wer wen kennen darf.
Jede Schicht kennt nur die direkt darunter. Diese eine Regel bringt drei Dinge mit, die man sonst mühsam nachrüstet:
- Ersetzbarkeit — die Datenbank wechseln, ohne den Controller anzufassen
- Testbarkeit — jede Schicht lässt sich für sich prüfen, weil man die darunter durch ein Doppel ersetzen kann (siehe Automatisiert testen)
- Auffindbarkeit — bei einem Fehler ist die Frage nicht „wo im Programm?", sondern „in welcher Schicht?"
Was in welche Schicht gehört
| gehört hinein | gehört nicht hinein | |
|---|---|---|
| Controller | Adresse und HTTP-Verb, Statuscode, Location-Kopf, @Valid | if-Regeln, Rechnungen, Repository-Aufrufe |
| Service | fachliche Regeln, Ausnahmen werfen, @Transactional, Mapper aufrufen | ResponseEntity, Statuscodes, HttpServletRequest |
| Repository | Methodensignaturen, @Query im Ausnahmefall | Regeln, Berechnungen, DTOs |
| Entität | Felder, Beziehungen, Datenbank-Annotationen | Jackson-Annotationen, Validierung für die Schnittstelle |
| DTO | Felder, Validierungs-Annotationen | Methoden mit Logik, JPA-Annotationen |
// so nicht
@GetMapping("/{id}")
public SupplierDto findById(@PathVariable Long id) {
return mapper.toDto(repository.findById(id).orElseThrow());
}
Das läuft — bis die erste Regel dazukommt („gelöschte Lieferanten nicht anzeigen"). Dann steht sie im Controller, und der nächste Endpunkt, der dasselbe braucht, hat sie nicht.
Der Service ist keine Durchreiche, die man sich sparen kann. Er ist der Ort, an dem die Regel einmal steht.
Der Mapper
Irgendwer muss Entität und DTO ineinander übersetzen. Diese Arbeit bekommt eine eigene Klasse:
@Component
public class SupplierMapper {
public SupplierDto toDto(Supplier supplier, long articleCount) {
return new SupplierDto(
supplier.getId(),
supplier.getName(),
toDto(supplier.getContact()),
articleCount);
}
public Supplier toEntity(CreateSupplierDto dto) {
Supplier supplier = new Supplier();
supplier.setName(dto.name());
supplier.setContact(toEntity(dto.contact()));
return supplier;
}
}
Drei Dinge sind daran wichtig:
toEntitysetzt keineid. Die vergibt die Datenbank. Ein Mapper, der die Kennung aus dem Eingangs-DTO übernimmt, hebelt genau den Schutz aus, für den es das Eingangs-DTO gibt.- Die Klasse weiß nichts von HTTP und nichts von der Datenbank. Deshalb ist sie mit
new SupplierMapper()testbar — ohne Spring, ohne Datenbank, in Millisekunden. - Sie steht neben dem Service, nicht neben dem Controller. Der Controller soll das DTO bekommen, nicht es sich selbst zusammenbauen.
MapStruct schreibt solche Methoden zur Übersetzungszeit selbst. In großen Projekten spart das viel Tipparbeit.
Für die Ausbildung ist die Handarbeit trotzdem der bessere Weg: Wer den Mapper einmal selbst geschrieben hat, sieht sofort, was ein erzeugter tut — und was er falsch macht, wenn er es tut.
Wenn LazyInitializationException auftritt
Ein Fehler, der genau an der Schichtgrenze entsteht: Der Service holt eine Entität, gibt sie zurück, und erst der Mapper greift auf eine faul geladene Beziehung zu. Zu diesem Zeitpunkt ist die Datenbank-Sitzung schon geschlossen.
Zwei Stellschrauben:
spring.jpa.open-in-view=falsein derapplication.properties— sie sorgt dafür, dass dieser Fehler früh und deutlich auftritt statt spät und unauffällig@Transactionalauf der Service-Methode — dann bleibt die Sitzung offen, solange der Service arbeitet, und der Mapper darf zugreifen
Die Einzelheiten dazu stehen im Infoblatt Beziehungen mit JPA abbilden.
Das Wichtigste in Kürze
- Die Entität beschreibt die Speicherung, das DTO die Schnittstelle. Beide dürfen sich getrennt entwickeln.
- Gibt man die Entität heraus, meldet sich kein Fehler: Der Kreis zwischen zwei Entitäten liefert 200 OK und 16 kB Unsinn.
orElseThrow()ohne Argument wirft eineNoSuchElementException— daraus wird eine 500. Für „gibt es nicht" gehört eine eigene fachliche Ausnahme hinein.- Ein Endpunkt wird nach dem Vorhaben des Clients geschnitten, nicht nach dem Aufbau der Tabellen.
- Zwei DTOs pro Ressource: eines für hinein (ohne
id, ohne Zeitstempel), eines für heraus. - Ein DTO ist ein
record— vier Zeilen, keine Logik. - Jede Schicht kennt nur die direkt darunter. Nie das Repository aus dem Controller.
- Der Mapper ist eine eigene Klasse, kennt weder HTTP noch Datenbank und ist deshalb ohne Spring testbar.
- Bei
LazyInitializationException:@Transactionalauf die Service-Methode, nichtopen-in-viewwieder einschalten.
Weiterlesen
- Fehlerantworten — was passiert, wenn eine Schicht eine Regel verletzt sieht
- Beziehungen mit JPA abbilden — warum die Entität sich nicht als Antwort eignet
- Automatisiert testen — jede Schicht bekommt ihre eigene Testart
- Webshop-Tutorial — dort baust du diese Schichten selbst