Zum Hauptinhalt springen

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 KreisDer 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 vielpasswordHash, internalNote, deletedAt — alles, was in der Tabelle steht, steht in der Antwort
Der Client bricht, wenn du die Tabelle änderstEin umbenanntes Feld ist eine Änderung der Datenbank. Sie darf nicht bei fremden Programmen ankommen
Der Client darf Dinge setzen, die er nicht setzen darfSchickt er beim Anlegen eine id mit, überschreibt er womöglich einen fremden Datensatz
Der Client braucht die Daten anders, als sie gespeichert sindEine 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();
}
Wirft 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.

Das Tückische ist nicht der Fehler, sondern dass keiner gemeldet wird

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.

Warum das im eigenen Projekt plötzlich auftritt

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

VersuchWarum er nicht trägt
@JsonIgnore auf articlesDas Feld ist damit überall weg — auch dort, wo man die Artikel gerade braucht
@JsonManagedReference / @JsonBackReferenceFunktioniert, 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 setzenAus 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.

Entität und DTO sehen sich ähnlich — sie sind es nicht

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:

Hinein: was der Client schicken darfCreateSupplierDtonamecontactkeine id, kein createdAtPOSTServervergibt die Kennung,setzt den ZeitstempelHeraus: was der Server zeigtSupplierDtoid, name, contact,articleCount201Der Client erfährt jetzt,welche Kennung er bekommen hat

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.

So ist es gespeichertcustomerid, first_name, last_nameaddressstreet, postcode, citycontactphone, emailGetrennt, damit nichts doppeltgespeichert wird.einAufrufSo wird es gebrauchtAnna MeyerAm Deich 1228199 Bremen0421 123456anna.meyer@example.deeine Karte, ein BlickCustomerCardDtoidfullNameaddressphoneemailnach dem Zweckgeschnitten

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 passiertWoran man es merkt
Zu wenig geliefertDer Client muss nachfragen, um eine Ansicht zu füllenFür eine Seite braucht es fünf Aufrufe. Man nennt das eine geschwätzige Schnittstelle
Zu viel geliefertJede Antwort schleppt Felder mit, die niemand ansiehtDie 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
Dieselbe Sache, zwei Ansichten, zwei DTOs

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 Grenze: nicht ein Endpunkt je Bildschirm

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

  1. Wer ruft das auf, und was hat er damit vor?
  2. Welche Felder braucht er dafür — und welche ganz sicher nicht?
  3. Muss er nach dieser Antwort noch einmal fragen, um seine Ansicht zu füllen? Wenn ja: Ist das der Regelfall oder die Ausnahme?
  4. 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.

Controllernimmt HTTP entgegen, gibt HTTP zurück — sonst nichts@RestControllerServicedie fachlichen Regeln — hier wohnt das Wissen@ServiceRepositorylesen und schreiben — du schreibst nur die SignaturJpaRepositoryDatenbankDTOEntitätMapperübersetzt zwischen beidenDie Pfeile zeigen nur nach unten: Der Service kennt das Repository, das Repository kennt den Service nicht.

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 hineingehört nicht hinein
ControllerAdresse und HTTP-Verb, Statuscode, Location-Kopf, @Validif-Regeln, Rechnungen, Repository-Aufrufe
Servicefachliche Regeln, Ausnahmen werfen, @Transactional, Mapper aufrufenResponseEntity, Statuscodes, HttpServletRequest
RepositoryMethodensignaturen, @Query im AusnahmefallRegeln, Berechnungen, DTOs
EntitätFelder, Beziehungen, Datenbank-AnnotationenJackson-Annotationen, Validierung für die Schnittstelle
DTOFelder, Validierungs-AnnotationenMethoden mit Logik, JPA-Annotationen
Der häufigste Verstoß: das Repository im Controller
// 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:

  1. toEntity setzt keine id. 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.
  2. Die Klasse weiß nichts von HTTP und nichts von der Datenbank. Deshalb ist sie mit new SupplierMapper() testbar — ohne Spring, ohne Datenbank, in Millisekunden.
  3. Sie steht neben dem Service, nicht neben dem Controller. Der Controller soll das DTO bekommen, nicht es sich selbst zusammenbauen.
Es gibt Werkzeuge, die den Mapper erzeugen

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=false in der application.properties — sie sorgt dafür, dass dieser Fehler früh und deutlich auftritt statt spät und unauffällig
  • @Transactional auf 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 eine NoSuchElementException — 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: @Transactional auf die Service-Methode, nicht open-in-view wieder einschalten.

Weiterlesen