1. Ein funktionierender Controller ist nicht immer eine gute API
Wenn man anfängt, eine API zu schreiben, ist der schönste Moment, wenn sie endlich antwortet. Du setzt ein @GetMapping, gibst ein Objekt zurück, führst ./gradlew bootRun aus, sendest eine Anfrage – und als Antwort kommt JSON. In diesem Moment sagt das Gehirn: „Alles klar, wir können feiern, wir sind Backend-Entwickler.“ Aber die unangenehme Wahrheit ist: Ein funktionierender Endpunkt ist noch kein Vertrag, es ist vorerst nur die Tatsache, dass Spring Ihr Objekt serialisieren kann.
Wir haben bereits ein funktionierendes Controller -> Service-Grundgerüst. Jetzt geht es nicht darum, ob der Endpunkt antworten kann, sondern darum, welchen JSON er dem Client tatsächlich verspricht.
Stellen Sie sich vor, Sie haben Folgendes geschrieben – „schnell mal eben“:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/{taskId}")
public Task getTask(@PathVariable String taskId) {
// Wichtig: So geben Sie das interne Modell direkt nach außen.
// Alles, was in Task vorhanden ist (Felder/Getter), kann Teil des öffentlichen JSON-Vertrags werden.
return taskService.findById(taskId);
}
Das funktioniert. Und genau deshalb ist es gefährlich.
Denn ab diesem Moment ist Ihr externer Vertrag (also das, was der Client sieht) nicht das, was Sie bewusst entworfen haben, sondern das, was zufällig entstanden ist aus dem aktuellen Zustand der Klasse Task. Heute hat sie diese Felder, morgen andere, und dem Client ist es normalerweise „egal“, was in Ihrem Inneren vorgeht: Er wird Stabilität erwarten.
2. Internes Modell
Das interne Modell ist die Art und Weise, wie die Anwendung Daten für sich selbst repräsentiert. Seine Aufgabe ist es, bequem für Services, Repositories, Update-Logik, Suche und generell dafür zu sein, dass der Code innerhalb des Projekts lesbar und wartbar ist. Und hier liegt der entscheidende Punkt: Das interne Modell wird sich per Definition häufiger ändern, als Sie es möchten. Nicht, weil Sie ein „schlechter Entwickler“ sind, sondern weil das Leben so ist – der Code wächst.
Nehmen wir an, Sie entscheiden sich heute, ein Dienstfeld hinzuzufügen, das Ihnen bei der Entwicklung oder beim Debuggen hilft:
import java.util.Set;
public class Task {
private String id;
private String title;
private Set<String> tags;
// Dienstfeld: nützlich innerhalb der Anwendung, sollte aber nicht Teil der öffentlichen API werden.
private String internalNote;
}
Hier kann internalNote innerhalb der Anwendung nützlich sein: Sie können dort etwas ablegen wie „warum die Aufgabe in den Seed-Daten aufgetaucht ist“ oder „welcher Test daran hängt“. Innen – okay. Aber wenn Sie Task nach außen geben, wird dieses Feld plötzlich Teil der öffentlichen API.
Und dies ist nicht die einzige Art von „internen“ Dingen. Das interne Modell enthält oft technische und infrastrukturelle Details, die der Client nicht benötigt: Zwischenwerte, normalisierte Felder, gecachte Darstellungen, Flags für die interne Logik. Es ist wie die Küche eines Restaurants: Es gibt dort viele nützliche Gegenstände (Messer, Lappen, Behälter), aber wenn man die Küche in den Gastraum stellt, denken die Gäste plötzlich nicht mehr, dass alles „Haute Cuisine“ ist.
3. Serialisierung von Task und HttpMessageConverter
Es ist sehr leicht, fälschlicherweise zu denken: „Nun, ich gebe ein Objekt zurück. Und JSON ist so etwas Separates, das irgendwo da draußen ‚von selbst‘ entsteht.“ In Spring MVC ist das kein separater Vorgang, sondern das direkte Ergebnis dessen, was Sie aus dem Controller zurückgegeben haben. Controller gibt Task zurück → Spring MVC findet einen passenden HttpMessageConverter → Jackson serialisiert das Objekt → Client erhält JSON.
Es ist hilfreich, sich das als einfaches Schema einzuprägen:
flowchart TD
A[Client: HTTP-Anfrage] --> B[Spring MVC Controller-Methode]
B --> C[Rückgabewert: Task]
C --> D[HttpMessageConverter]
D --> E[Jackson serialisiert in JSON]
E --> F[Client: HTTP-Antwortbody]
Wichtige praktische Erkenntnis: Wenn Sie ein internes Objekt zurückgeben, dann haben Sie Spring buchstäblich gesagt: „Bitte verwandle das hier in einen öffentlichen Vertrag.“ Jackson wird die Eigenschaften des Objekts betrachten (normalerweise über Getter, manchmal über Felder – je nach Sichtbarkeitsregeln) und daraus gewissenhaft JSON erzeugen.
Eine besonders lustige (und unangenehme) Situation ist, wenn Sie einen „harmlosen“ Getter hinzufügen, den Sie intern benötigen, und dieser plötzlich zu einem Feld der Antwort wird:
public String getSearchIndex() {
// Interner berechneter Getter: praktisch für die Suche innerhalb des Services/Repositories,
// aber bei direkter Ausgabe von Task nach außen kann er als Feld searchIndex im JSON „auftauchen“.
return (title + " " + description).toLowerCase();
}
Im Projekt wollten Sie nur „ein praktisches Stück für die Suche“. Nach außen fliegt nun potenziell searchIndex, und der Client beginnt zu denken, dass dies ein wichtiges Vertragsfeld ist. Glückwunsch: Sie haben gerade öffentlich etwas versprochen, das Sie nie versprechen wollten.
4. Durchsickern unnötiger Felder
Das offensichtlichste Problem der direkten Ausgabe des internen Modells ist das Durchsickern unnötiger Daten. Dabei muss es sich nicht unbedingt um „Geheimnisse auf dem Niveau von Bankschlüsseln“ handeln (die man ohnehin nicht so in der Domänenmodell speichern sollte), es kann einfach Überflüssiges sein: Technisches, Zwischenergebnisse, Temporäres. Aber sobald der Client es gesehen hat, kann er anfangen, sich darauf zu stützen.
Betrachten wir ein Miniszenario. Das interne Modell ist etwas reicher geworden:
public class Task {
private String id;
private String title;
private String status;
// Technisches Feld: für den Client „überflüssig“, aber bei direkter Serialisierung sickert es in die Antwort.
private String internalNote; // "nicht nach außen geben", aber wer soll es stoppen?
}
Und der Controller gibt immer noch „freudig Task“ zurück:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/{taskId}")
public Task getTask(@PathVariable String taskId) {
// Solange wir Task direkt zurückgeben – jedes neue Feld/Getter in Task wird potenziell zum API-Feld.
return taskService.findById(taskId);
}
Was sieht der Client? In etwa folgendes JSON (vereinfacht):
{
"id": "b7b5b6b7-3d33-4c2f-a9b5-3d3d3d3d3d3d",
"title": "Pay rent",
"status": "TODO",
"internalNote": "seed:task-42"
}
Und hier beginnt die „Komödie der Irrungen“. Der Client könnte denken: „Oh, internalNote, das ist also eine nützliche Notiz, wir zeigen sie im UI an.“ Oder „internalNote ist ein praktischer Identifikator.“ Oder „Danach kann man filtern.“ Der Client ist nicht verpflichtet zu erraten, dass es sich um einen „temporären Krückstock“ oder eine „interne Sache“ handelt.
Am Ende werden Sie zur Geisel: Ein Feld, das zufällig in der Antwort auftauchte, wird zum Teil des Vertrags. Es später zu entfernen ist bereits ein Breaking Change.
5. Zufällige Breaking Changes
Das zweite große Problem ist die Zerbrechlichkeit des Vertrags. Sie haben das Recht, den internen Code zu verbessern: Felder umzubenennen, Typen zu ändern, ein Feld in zwei aufzuteilen, einen Teil der Logik in ein anderes Objekt auszulagern. Aber wenn das externe JSON direkt dem internen Modell folgt, wird jede Refaktorisierung zu einem „öffentlichen Ereignis“.
Stellen wir uns eine sehr alltägliche Situation vor. Im Projekt haben Sie das Feld assigneeName in executorName umbenannt, weil es für Sie verständlicher ist (oder weil in der Domäne des Unternehmens das Wort „Ausführender“ statt „Zugewiesener“ üblich ist). Das ist eine normale Refaktorisierung.
Vor der Refaktorisierung sah der Client:
{
"id": "…",
"title": "Call mom",
"assigneeName": "Alex"
}
Nach der Refaktorisierung sieht der Client plötzlich:
{
"id": "…",
"title": "Call mom",
"executorName": "Alex"
}
Für den Server ist das „ich habe nur Ordnung geschaffen“. Für den Client ist das „Ihre API ist kaputt“. Und das Unangenehmste: Sie haben nicht einmal eine Rechtfertigung außer einem ehrlichen „naja, wir haben da drinnen umbenannt, sorry“.
Und ja, manchmal gilt das Hinzufügen eines neuen Feldes zur Antwort als sichere Änderung (Clients ignorieren normalerweise unbekannte Felder). Aber Sie kontrollieren nicht alle Clients auf der Welt, besonders wenn die API öffentlich wird. Manche Clients sind „streng“ und stürzen bei einem unerwarteten Feld ab (selten, aber es kommt vor). Daher ist die Strategie „wir ändern das interne Modell, wie wir wollen, und die Clients müssen damit leben“ keine Strategie, sondern eine Möglichkeit, zukünftigen Schmerz anzuhäufen.
6. Vertrag und Darstellungen: Liste vs. Details
Das dritte Problem der direkten Ausgabe des internen Modells ist die Unmöglichkeit, die Darstellungen einer Ressource richtig zu verwalten. In der REST-Welt hat dieselbe Ressource oft verschiedene Formen in verschiedenen Operationen: Eine Liste erfordert normalerweise Kürze, ein Detail-Endpunkt Vollständigkeit, und manche internen Felder sollten überhaupt nie nach außen erscheinen. Wichtig ist uns hier das Prinzip: Ein Domänenmodell muss nicht alle externen Darstellungen gleichzeitig bedienen.
Selbst wenn Ihr Task jetzt „sauber“ aussieht, werden Sie morgen möglicherweise eine lange Beschreibung, Dienstfelder oder berechnete Eigenschaften hinzufügen wollen. Und wenn Sie Task direkt zurückgeben, beginnt die Aufgabenliste alles Mögliche „mitzuschleppen“. Am Ende wird GET /api/v1/tasks schwer und verrauscht, nur weil Sie irgendwo intern ein Feld hinzugefügt haben.
Es sieht so aus: Sie möchten, dass die Liste kompakt und schnell für den Client ist. In der Liste reichen normalerweise id, title, status. Aber wenn Sie den internen Task zurückgeben, erhält der Client unweigerlich auch description, internalNote und alles, was Sie später „für sich selbst“ hinzufügen.
Ja, man kann versuchen, sich herauszuwinden: Felder vor der Ausgabe nullen, „wenn es ein Listen-Endpunkt ist“, bedingte Serialisierungslogik schreiben, manuell eine Map<String, Object> konstruieren. Aber das endet normalerweise damit, dass Sie nicht einen Vertrag haben, sondern viele zufällige Verträge und keinen stabilen.
7. Response DTO als „Pressemitteilung“ Ihrer Ressource
Ein Response DTO ist Ihr offizielles öffentliches Antwortmodell. Wenn das interne Modell ein Arbeitsheft ist, in dem man durchstreichen, umschreiben und Entscheidungen im laufenden Betrieb ändern kann, dann ist das Response DTO die „Druckversion“, die Sie der Außenwelt geben. Der wichtige Gedanke: Der Client muss nicht wissen, wie Sie die Daten intern speichern. Der Client muss wissen, was genau Sie zu liefern versprechen.
Vorerst ist es unwichtig, wie genau die verschiedenen Antwortvarianten heißen. In diesem Schritt reicht es, eine grundlegendere Sache zu sehen: Nach außen sollte ein separates Response DTO gehen, nicht Task.
Zum Beispiel könnte eine minimale Antwort so aussehen:
public class TaskResponse {
// Die Felder des DTO sind der Vertrag: Sie fügen sie nur bewusst hinzu oder entfernen sie bewusst.
private final String id;
private final String title;
private final String status;
public TaskResponse(String id, String title, String status) {
// DTO ist normalerweise einfach: Es überträgt genau die Daten, die wir dem Client zeigen wollen.
this.id = id; this.title = title; this.status = status;
}
// Die Getter des DTO sind die „erlaubten“ Antwortfelder.
public String getId() { return id; }
public String getTitle() { return title; }
public String getStatus() { return status; }
}
Jetzt hat Ihre API eine explizite Grenze: Was auch immer innerhalb von Task passiert, nach außen geht trotzdem genau das, was Sie in TaskResponse gelegt haben. Wenn Sie möchten, fügen Sie ein Feld hinzu. Wenn nicht, dann nicht. Keine Überraschungen „oh, ich habe internalNote hinzugefügt und es wurde öffentlich“.
8. Mini-Refaktorisierung im Task Tracker API
Damit dies nicht wie eine Theorie aus einem Buch klingt, machen wir einen minimalen Schritt direkt in unserem Task Tracker API. Wir brauchen noch nicht den vollständigen Satz an DTOs für alle Operationen; zunächst ist es wichtiger, einfach aufzuhören, das interne Objekt direkt aus dem Controller zurückzugeben.
Stellen wir uns vor, wir haben einen internen Task:
public class Task {
private String id;
private String title;
private String status;
// Internes Feld: kann für den Server sehr wichtig sein, aber der Client muss nichts davon wissen.
private String internalNote;
public String getId() { return id; }
public String getTitle() { return title; }
public String getStatus() { return status; }
}
Jetzt gibt der Controller statt Task ein TaskResponse zurück:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/{taskId}")
public TaskResponse getTask(@PathVariable String taskId) {
// Intern können wir das Domänenmodell mit allen technischen Feldern abrufen.
Task task = taskService.findById(taskId);
// Nach außen geben wir genau das, was wir als Teil des API-Vertrags betrachten.
return new TaskResponse(task.getId(), task.getTitle(), task.getStatus());
}
Der entscheidende Punkt: Sie „bauen“ die Antwort manuell zusammen. Das erscheint wie zusätzliche Arbeit, aber genau darin liegt der Sinn: Sie machen den Vertrag explizit. Das interne Feld internalNote kann nun leben, sich ändern, sich vermehren – es wird nicht in den JSON gelangen, weil Sie es nicht in das DTO aufgenommen haben.
Das Ergebnis für den Client wird vorhersagbar und kurz:
{
"id": "…",
"title": "Pay rent",
"status": "TODO"
}
Von nun an, wenn Sie das interne Modell ändern, spielen Sie nicht mehr „Rate mal, was der Client sieht“. Der Client sieht nur das, was Sie in das Response DTO gelegt haben.
### Internes Modell vs. Response DTO
Um nicht all dies als abstrakte Worte im Kopf zu behalten, ist es nützlich, den Vergleich einmal „an die Wand zu nageln“. Das interne Modell und das Response DTO ähneln sich darin, dass beide Java-Klassen sind. Aber ihre Rollen sind unterschiedlich, und sie zu vermischen ist zumindest schädlich für die Psyche des Teams (und manchmal auch für die API).
| Aspekt | Internes Modell | Response DTO |
|---|---|---|
| Hauptziel | Bequemlichkeit der Logik innerhalb der Anwendung | Expliziter und stabiler externer Vertrag |
| Wer ist „Eigentümer“ | Server (Sie und Ihr Team) | API-Clients (und Sie als Autor des Vertrags) |
| Änderungshäufigkeit | Ändert sich häufig durch Refaktorisierung und Weiterentwicklung | Ändert sich vorsichtig, bewusst |
| Was kann enthalten | Dienstfelder, Caches, Hilfseigenschaften | Nur das, was Sie nach außen versprechen wollen |
| Optimierung | Kann für Speicherung und interne Arbeit optimiert werden | Optimieren Sie für Lesbarkeit und Bequemlichkeit des Clients |
| Fehlerkosten | Sie brechen die interne Logik (normalerweise schnell reparierbar) | Sie brechen Clients und das Vertrauen in die API (Reparatur dauert lange) |
Kurz gesagt: Das interne Modell ist „wie es für uns bequem zu arbeiten ist“, das DTO ist „wie wir uns mit der Außenwelt abstimmen“.
9. Typische Fehler bei der Arbeit mit DTOs
Fehler Nr. 1: „Solange das Projekt klein ist, kann man das interne Modell zurückgeben.“
Das ist eine sehr menschliche Logik, aber sie führt genau deshalb in die Irre, weil ein kleines Projekt schnell aufhört, klein zu sein. Sie fügen ein Dienstfeld hinzu, einen berechneten Getter, einen Verweis auf ein internes Objekt – und der externe Vertrag beginnt sich auszubreiten. Später müssen Sie „Entschärfungsarbeit“ leisten und den Clients erklären, warum plötzlich ein Feld verschwunden ist, das Sie „nie versprochen haben“.
Fehler Nr. 2: Der Versuch, das interne Modell „ideal für die API“ zu machen, anstatt ein DTO zu verwenden.
Manchmal scheint es, als könnte man das Problem so lösen: „Lasst uns einfach sehr vorsichtig sein und nichts Überflüssiges zu Task hinzufügen.“ Das führt dazu, dass das interne Modell beginnt, den externen Vertrag zu bedienen, anstatt die interne Logik. Am Ende leiden sowohl der Vertrag (weil es trotzdem Lecks geben wird) als auch der interne Code (weil Sie sich fürchten, das Modell zu ändern, selbst wenn es nötig ist).
Fehler Nr. 3: Nicht daran zu denken, dass ein öffentlicher Getter fast ein öffentliches API-Feld ist.
Sie fügen eine Methode getSomethingUseful() aus internen Gründen hinzu, denken nicht an den Controller, und wundern sich dann, dass der Client plötzlich ein Feld somethingUseful im JSON erhält. Das ist besonders tückisch in Projekten, in denen man gerne „schöne“ berechnete Getter hinzufügt. Wenn das Objekt nach außen gelangt, werden die Getter Teil der API, selbst wenn Sie das nicht geplant haben.
Fehler Nr. 4: „Wie es ist“ zurückgeben und dann versuchen, es mit Hacks zu beheben.
Hierzu gehören Ansätze wie: „Lasst uns vor der Ausgabe in einem Listen-Endpunkt description nullen“, „lasst uns Felder im laufenden Betrieb aus dem Objekt entfernen“, „lasst uns eine Map<String, Object> zurückgeben und das JSON von Hand zusammenbauen“. Normalerweise verwandelt das den Code in eine Ansammlung von Krücken, weil der Vertrag trotzdem implizit bleibt und die Logik zur Erstellung der Antwort über die Controller verteilt wird.
Fehler Nr. 5: DTO und Domänenmodell im selben Paket und mit denselben Namen vermischen.
Wenn Sie Task sowohl in domain.model als auch in api.dto.response erstellen und dann beginnen, den „falschen Task“ zu importieren (ja, das passiert regelmäßig), wird das Projekt zu einem Krimi: „Welcher von ihnen ist der echte Task?“ Es ist besser, sich von Anfang an eine Disziplin anzugewöhnen: Interne Modelle leben in domain.model, öffentliche Antworten in api.dto.response, und sie werden so benannt, dass die Rolle aus dem Namen ersichtlich ist.
GO TO FULL VERSION