1. DTO-Refactoring und Vertrag
Wenn wir einen Server schreiben, haben wir oft das Gefühl, dass das wichtigste Objekt der Welt die Java-Klasse ist. Ich habe ein Feld geändert, eine record-Komponente umbenannt, Tests laufen lassen – alles sieht gut aus. Aber den Client (Mobile-App, Frontend, Partner-Integration, Analysten-Skript) interessiert Ihre Java-Klasse überhaupt nicht. Der Client lebt in der Welt von JSON: in der Welt der Feldnamen, ihrer Typen, Verschachtelungen und der Wurzelform der Antwort. Und wenn der Client Code hat, der JSON nach bestimmten Regeln parst, dann ist jede Änderung dieser Regeln keine „interne Verbesserung“, sondern ein Vertragswechsel. In diesem Moment ist es besonders hilfreich, sich an die traurige Wahrheit zu erinnern: Clients lesen Ihre Commit-Nachrichten nicht, sehen Ihren PR nicht und wissen nicht, dass Sie nur assigneeName in assignee umbenannt haben, weil es „kürzer ist“. Sie hören einfach auf zu funktionieren.
Stellen wir uns eine typische Situation vor. Der Client hat einen Parser für Ihre Antwort geschrieben und erwartet das Feld items. Sie entscheiden, dass items langweilig ist, und benennen es in data um. Für Sie sind das fünf Sekunden, für den Client ein Laufzeitfehler oder ein leerer Bildschirm. Und ja, der Satz „aber es ist doch nur JSON“ hilft nicht: JSON ist genau das, was der Client als Wahrheit betrachtet.
Ein kleines Schema, wie eine „harmlose Änderung“ zu einem Vorfall wird:
flowchart LR
A[Server ändert JSON-Antwort] --> B[Client parst nach alten Erwartungen]
B --> C{Stimmt überein?}
C -- ja --> D[Alle glücklich]
C -- nein --> E[Parse-Fehler / leere Daten / Absturz]
E --> F[Benutzer unzufrieden]
Kompatibilität: Strenge und tolerante Clients
Das Wort „Kompatibilität“ klingt oft abstrakt, wie „architektonische Harmonie“. In der Praxis ist es eine sehr alltägliche Sache: Kann ein bereits veröffentlichter Client (den Sie nicht sofort aktualisieren können) nach Ihrer Änderung weiter funktionieren? Und hier wird es interessant: Verschiedene Clients verhalten sich unterschiedlich. Ein Client ignoriert unbekannte Felder einfach und freut sich des Lebens, ein anderer validiert die Antwort wie ein strenger Lehrer in der Schule und fällt bei jedem „überflüssigen“ Feld in Ohnmacht. Mal hängt es von den Einstellungen der JSON-Bibliothek ab, mal von generierten Modellen, mal davon, dass jemand einmal if (!json.has("field")) throw ... geschrieben hat – und in den Urlaub gegangen ist.
Deshalb bedeutet „normalerweise kompatibel“ im API-Design fast immer: „kompatibel für die meisten vernünftigen Clients, die sich nicht wie Paranoiker verhalten“. Aber Sie können nicht garantieren, dass alle Clients genau so sind. In dieser Vorlesung werden wir ehrlich sagen: Welche Änderungen am häufigsten als sicher gelten (additive), welche mit ziemlicher Sicherheit brechen (breaking), und welche sich in einer Grauzone befinden, in der alles vom Client und Ihren Vereinbarungen abhängt.
Ein weiterer wichtiger Punkt: In unserem Kurs ist das API-Versioning auf Pfadebene bereits als /api/v1 festgelegt. Das ist kein „magischer Schutz“ vor Fehlern, aber eine nützliche Disziplin. Sie erinnert daran, dass ein öffentlicher Vertrag länger lebt als einen Tag. Wenn Sie den Vertrag brechen, machen Sie im Grunde genommen kein „v1“ mehr, sondern etwas Neues. Und das sollte man normalerweise nicht stillschweigend tun.
2. Additive Change: Hinzufügen eines Feldes
Das Hinzufügen eines neuen Feldes in einer JSON-Antwort gilt normalerweise als die sicherste Art der Vertragsentwicklung. Der alte Client liest seine Felder wie zuvor, das neue Feld wird einfach ignoriert. Der Server kann dabei beginnen, mehr Daten zurückzugeben, ohne alte Erwartungen zu zerstören. Das ist der klassische additive Change: „Hinzugefügt, nichts weggenommen und die Bedeutung nicht geändert“.
Im Task Tracker API sieht diese Art von Änderung sehr natürlich aus. Zum Beispiel haben Sie sich entschieden, in der Summary-Antwort explizit anzuzeigen, ob eine Aufgabe archiviert ist (auch wenn dies aus dem status abgeleitet werden kann). Aus Clientsicht ist das praktisch: Man muss nicht selbst Schlussfolgerungen ziehen. Und diese Hinzufügung sollte alte Konsumenten nicht brechen – sie lesen einfach weiterhin id und title.
Beispiel für die Evolution eines DTO „direkt“ auf Codeebene (dies demonstriert die Idee, nicht die Empfehlung, Versionen als separate Klassen für immer zu behalten):
public record TaskSummaryResponseV1(
/* Aufgaben-ID (im Vertrag – String, z. B. UUID). */
String id,
/* Menschenlesbarer Titel der Aufgabe. */
String title
) {}
public record TaskSummaryResponseV2(
/* Aufgaben-ID – bleibt unverändert, um Clients nicht zu brechen. */
String id,
/* Aufgabentitel – bleibt ebenfalls wie gehabt. */
String title,
/* Neues Feld: Alte Clients ignorieren es normalerweise einfach. */
boolean archived
) {}
Wenn der Client nur id und title liest, funktioniert er weiter. Wenn der Client „schlau“ ist und archived haben möchte, aktualisiert er sich und beginnt das neue Feld zu nutzen.
Der Haken hier ist, dass ein additive Change keine Lizenz für Chaos ist. Wenn Sie jeden Sprint „ein bisschen Nützliches hinzufügen“, wird das DTO zu einem Koffer ohne Griff: Man muss ihn zwar tragen, aber es ist unmöglich, damit zu leben. Daher ist additive Change als Kompatibilitätstechnik sicher, aber nicht immer als Designtechnik nützlich. Stabilität eines Vertrags bedeutet nicht „ständig erweitern“, sondern bewusst und an den richtigen Stellen erweitern.
3. Umbenennung eines Feldes: Breaking Change
Die Umbenennung eines Feldes ist der häufigste Weg, Clients versehentlich zu brechen. Auf dem Server sieht es nach Ästhetik aus: „assigneeName ist zu lang, soll assignee heißen“. Beim Client sieht es so aus: „Feld verschwunden“, weil sich der JSON-Key geändert hat. Und wenn der Client assigneeName erwartet, wird er ihn nie finden.
So sieht ein solcher „kleiner Refactoring“ aus:
public record TaskSummaryResponseOld(
/* Aufgaben-ID. */
String id,
/* Alter Name des JSON-Feldes, auf das der Client bereits einen Parser geschrieben haben könnte. */
String assigneeName
) {}
public record TaskSummaryResponseNew(
/* Aufgaben-ID – unverändert. */
String id,
/* Neuer Komponentenname: Standardmäßig ändert Jackson auch den JSON-Key. */
String assignee
) {}
Für Java sind das unterschiedliche Komponentennamen, also gibt Jackson standardmäßig unterschiedliche JSON-Feldnamen aus. Der Client, der assigneeName erwartet hat, sieht plötzlich dessen Abwesenheit. Selbst wenn assignee denselben Wert enthält, weiß der Client einfach nicht, dass „das alte Feld umgezogen ist“.
Und hier kommt ein wichtiger Gedanke, den man buchstäblich in der Tasche tragen sollte: Eine interne Umbenennung in Java muss nicht das externe JSON ändern. Wenn Sie die Lesbarkeit des Codes auf dem Server verbessern möchten, aber den Vertrag nicht brechen dürfen, können Sie den Feldnamen im JSON über das bereits bekannte @JsonProperty „fixieren“.
Minibeispiel für ein Response-DTO:
import com.fasterxml.jackson.annotation.JsonProperty;
public record TaskSummaryResponse(
/* Aufgaben-ID. */
String id,
/* In Java heißt die Komponente assignee, aber nach außen geht der bisherige Schlüssel assigneeName. */
@JsonProperty("assigneeName") String assignee
) {}
Das Java-Feld heißt jetzt assignee, nach außen geht aber derselbe JSON-Key assigneeName. Das ist ein Beispiel dafür, wie man ein „Refactoring“ zu einem echten Refactoring macht: den internen Code verbessern, ohne den externen Vertrag zu brechen.
Nur sollten Sie das nicht zum Sport machen, „das DTO mit Annotationen zu überziehen“. Wenn Sie ständig Felder umbenennen und die alten JSON-Namen über @JsonProperty behalten, entsteht schnell die Situation „Java sagt dies, JSON sagt das, und der Neue im Projekt weint“. Aber als Werkzeug für einen sanften Übergang oder um öffentliche Namen nicht zu brechen, ist es nützlich.
4. Typänderung eines Feldes: JSON vs. Java
Die Typänderung ist eine tückische Sache, weil sie zwei Ebenen hat. Es gibt den Java-Typ im DTO und den JSON-Typ „auf der Leitung“. Und diese beiden Welten passen im Kopf eines Anfängers nicht immer zusammen. Zum Beispiel sind LocalDate in Java und der String "2026-03-21" in JSON ein völlig normales Paar. Der Client sieht trotzdem einen String. Das heißt, Sie können den Java-Typ von String auf LocalDate ändern (auf dem Server), und der Client bemerkt überhaupt nichts, solange das JSON-Format dasselbe bleibt (ISO-String).
Wenn Sie jedoch das Format auf einen Epochen-Zeitstempel ändern, bemerkt der Client sofort, und oft unangenehm. Gestern sah er "dueDate": "2026-03-21", heute "dueDate": 1774041600. Das ist ein anderer JSON-Typ (string → number), und das ist ein typischer Breaking Change.
Mini-Illustration „vorher / nachher“ auf JSON-Ebene:
{
"id": "a3b2...",
"dueDate": "2026-03-21"
}
{
"id": "a3b2...",
"dueDate": 1774041600
}
Dasselbe gilt für IDs. In unserem Projekt ist laut Lastenheft id ein UUID-String. Wenn Sie plötzlich entscheiden, id als Zahl zurückzugeben, brechen Sie praktisch alles. Selbst wenn jemand im Browser „die Zahl sieht und versteht“, kann der Client-Code id als String speichern, als Schlüssel verwenden, URLs zusammenbauen, in eine Map legen – und überall hat er Erwartungen an einen String-Typ.
Daher gilt eine sehr einfache, aber nützliche Regel: Wenn sich der JSON-Typ ändert (string/number/object/array/boolean/null), betrachten Sie das standardmäßig als Breaking Change. Selbst wenn „es logisch dasselbe ist“, die Client-Bibliothek könnte nicht so philosophisch sein.
5. Wurzelform der Antwort: Array und Objekt
Die Wurzelform der Antwort ist das, womit JSON beginnt. Und das ist der „inkompatibelste“ Teil des Vertrags, weil der Client die Wurzel normalerweise streng parst. Wenn er früher ein Array erwartet hat und jetzt ein Objekt kommt, sagt der Parser nicht „na gut, ich rate mal“. Er sagt: „Ich erwartete [ aber sah {“ – und fertig.
Deshalb haben wir so ausführlich über raw array versus envelope diskutiert. Wenn Sie bereits GET /api/v1/tasks als „nacktes Array“ veröffentlicht haben und dann beschließen, auf das bereits gewählte PagedResponse<T> umzusteigen, dann ist das keine „Verbesserung“, sondern eine API-Änderung, die mit alten Clients inkompatibel ist.
Zeigen wir das im Code auf Controllerebene (vereinfacht, um nur die Signatur zu sehen).
So war es (Wurzel – JSON-Array):
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
class TaskController {
// Client erwartet an der Wurzel ein JSON-Array: [...]. Jeder Wechsel der Wurzel ist ein Breaking Change.
@GetMapping("/api/v1/tasks")
List<TaskSummaryResponse> list() {
// Beispiel: leere Liste zurückgeben (in Wirklichkeit wäre hier ein Service/Repository).
return List.of();
}
}
So wurde es (Wurzel – JSON-Objekt mit items und Metadaten):
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
class TaskController {
// Jetzt ist die Wurzel der Antwort ein Objekt: { "items": [...], ... }.
// Der alte Client, der ein JSON-Array erwartet, wird hier mit ziemlicher Sicherheit brechen.
@GetMapping("/api/v1/tasks")
PagedResponse<TaskSummaryResponse> list() {
// Selbst eine leere Liste wird jetzt über denselben Vertrag dargestellt: items + Metadaten.
return new PagedResponse<>(List.of(), 0, 20, 0, 0, "updatedAt,desc");
}
}
Selbst wenn „alle Elemente gleich sind“, muss der Client das Parsen der Antwort umschreiben, weil sich die Wurzel geändert hat. Daher sollte die Wurzelform im Voraus gewählt und wie ein „Fundament des Hauses“ behandelt werden: Ja, man kann das Haus umbauen, aber das ist keine abendliche Fünf-Minuten-Änderung.
6. Nullability: null und Abwesenheit eines Feldes
null, das Fehlen eines Feldes und ein leerer Wert sind nicht nur „Geschmackssache der Serialisierung“. Sie haben eine Bedeutung. Der Client kann Entscheidungen auf der Grundlage treffen, ob ein Feld existiert, ob es gleich null ist, oder ob es eine leere Sammlung ist. Dabei sind die Entscheidungen in der Realität manchmal seltsam, weil Leute Code unter Zeitdruck schreiben. Aber der Vertrag sollte genau dagegen stabil sein.
Nehmen wir das Beispiel assigneeName. In der Detail-Antwort haben wir vereinbart: Das Feld existiert als Teil des Vertrags, aber wenn kein Bearbeiter zugewiesen ist, ist es null. Das ist ein gutes, klares Signal: „Feld existiert, aber kein Wert“.
import java.util.List;
public record TaskDetailsResponse(
/* Aufgaben-ID. */
String id,
/* Aufgabentitel. */
String title,
/* Feld ist immer vorhanden, aber der Wert kann null sein, wenn kein Bearbeiter zugewiesen ist. */
String assigneeName,
/* Sammlung von Tags: laut Vertrag besser [] anstelle von null zurückgeben. */
List<String> tags
) {}
Wenn Sie morgen entscheiden: „Lassen wir assigneeName nicht zurückgeben, wenn es null ist“, dann ist das für den Client eine andere Realität. Gestern sah er "assigneeName": null, heute fehlt das Feld. Für manche Clients ist das gleichbedeutend, für andere nicht. Jemand macht json.get("assigneeName").isNull(), und jemand prüft json.has("assigneeName"). Und beide Ansätze kommen in der Praxis wirklich vor, ohne Scherz.
Dasselbe gilt für Sammlungen. In unserem Antwortstil sind leere Tags [], nicht null. Wenn Sie anfangen, tags: null zurückzugeben, könnte der Client, der for (tag : tags) macht, eine Überraschung erleben. Ja, formal sollte der Client vorsichtig sein. Aber der Vertrag in unserem Projekt wird bewusst „benutzerfreundlich“ gestaltet: Wenn eine Liste als Vertragsfeld existiert, sollte sie eine Liste sein, selbst wenn sie leer ist.
Und noch ein subtiler Punkt – der „server-seitige Standardwert“. Zum Beispiel archived als boolean. Wenn dies ein wichtiger Teil des Zustands ist, ist es besser, false zurückzugeben, als „irgendwie von selbst zu verschwinden“. Ein Boolean ist wie eine Ampel: Eine fehlende Ampel verbessert selten die Verkehrssicherheit.
7. Semantik als Teil des Vertrags
Die heimtückischste Art von Fehlern – wenn das JSON-Format gleich bleibt, aber die Bedeutung sich ändert. Auf dem Server heißt das normalerweise „wir haben die Logik ein wenig präzisiert“, und beim Client: „warum funktioniert der Knopf jetzt umgekehrt?“. Das ist kein Scherz: Ein Vertrag besteht nicht nur aus Struktur und Typen, sondern auch aus Semantik.
Zum Beispiel das Feld archived. Heute haben wir vereinbart, dass es aus status == ARCHIVED abgeleitet wird. Morgen entscheidet jemand: „Archivierung ist ein separates Flag, und status soll DONE sein und archived=true“. Das JSON-Format ist dasselbe (archived ist immer noch ein boolean), aber die Bedeutung ist eine andere. Der Client kann Aufgaben nicht mehr richtig filtern, sie in den falschen Abschnitten anzeigen usw.
Ein weiteres Beispiel sind Enum-Werte. Angenommen, Sie haben einen neuen TaskStatus hinzugefügt, z. B. CANCELLED. Formal haben Sie „nichts gebrochen“: Es ist ein additive Change im Sinne der Aufzählung. Aber wenn der Client status als Enum mit einer strengen Menge von Werten parst und bei einem unbekannten Wert abstürzt, dann bricht er. Deshalb sollte selbst das Hinzufügen von Enum-Werten bewusst erfolgen, und man muss verstehen, dass dies für manche Clients ein Breaking Change sein kann. In echten APIs wird dies durch Vereinbarungen, Versionierung oder eine Strategie des toleranten Parsens gelöst, aber zumindest sollten Sie das Risiko sehen.
Und noch ein „semantisches“ Beispiel, das oft vergessen wird: die Reihenfolge der Elemente in einer Liste. Wenn der Client das „erste Element“ als „das aktuellste“ verwendet, dann ist eine Änderung der Sortierung eine Verhaltensänderung. Selbst wenn das JSON-Format identisch ist, ändert die Client-Logik die Bedeutung. Deshalb ist sort in PagedResponse kein dekorativer String, sondern Teil des ehrlichen Vertrags: „Wir geben so zurück, und zwar genau so“.
Merktafel zur Kompatibilität
Wenn Sie eine Änderung an einem Response-DTO vornehmen, ist es hilfreich, keine moralische Unterstützung, sondern eine kurze technische Karte vor Augen zu haben. Da wir heute nicht in Listen ertrinken wollen, machen wir eine Tabelle – sie funktioniert besser als „tausend Punkte“.
| Änderung in der Antwort | Meistens kompatibel? | Warum | Beispiel für Task Tracker API |
|---|---|---|---|
| Neues Feld hinzugefügt | Ja | Alte Clients ignorieren Überflüssiges normalerweise | archived zu TaskSummaryResponse hinzugefügt |
| Neues Objekt/verschachtelten Block hinzugefügt (ohne Änderung alter Felder) | Normalerweise ja | Wieder additive Change | metrics neben items im Envelope hinzugefügt (wenn die Wurzel bereits ein Objekt ist) |
| Feld umbenannt | Nein | Client erwartet den alten Namen | assigneeName → assignee |
| Feld entfernt | Nein | Client verwendet das Feld | priority aus Summary entfernt |
| JSON-Typ eines Feldes geändert | Nein | Parser/Modell des Clients erwartet einen anderen Typ | "dueDate": "2026-03-21" → "dueDate": 1774041600 |
| Wurzelform geändert | Nein | Client parst die Wurzel streng | [] → { "items": [] } |
| Nullability/Anwesenheit geändert (null ↔ fehlt) | Grauzone | Hängt vom Client und der Vereinbarung ab | assigneeName: null → Feld verschwunden |
| Neuen Enum-Wert hinzugefügt | Grauzone | Hängt von der „Strenge“ des Clients ab | Neuer TaskStatus |
| Bedeutung eines Feldes ohne Formänderung geändert | Faktisch nein | Client-Logik wird gebrochen | archived entspricht nicht mehr status == ARCHIVED |
Diese Tabelle ist kein Naturgesetz, aber ein guter „Risikodetektor“. Wenn Sie „nein“ oder „Grauzone“ sehen, sollten Sie innehalten und überlegen: Machen Sie nicht einen Breaking Change unter dem Deckmantel „wir haben nur ein bisschen verbessert“.
8. Typische Fehler bei Änderungen an Response-DTOs
Fehler Nr. 1: „Das ist doch nur Refactoring, was regt ihr euch auf?“
Am häufigsten wird das gesagt, wenn Felder umbenannt oder die Wurzelform der Antwort geändert wird. Aus Serversicht ist das wirklich „Refactoring“. Aus Clientsicht ist es ein Vertragswechsel. Und wenn der Client nicht gleichzeitig mit dem Server aktualisiert wird (und das wird er fast nie), bekommen Sie einen Bruch.
Fehler Nr. 2: „Lassen Sie uns ein Array zurückgeben, und Metadaten fügen wir später irgendwie hinzu.“
Das ist die klassische Falle von List-Endpunkten. Zuerst wird ein raw array zurückgegeben, weil „es einfacher ist“. Dann tauchen totalElements, page, sort auf – und plötzlich stellt sich heraus, dass der einzige Weg, Metadaten hinzuzufügen, die Wurzel der Antwort ändert und Clients bricht. Daher ist es besser, die Envelope-Entscheidung im Voraus zu treffen, solange die API noch nicht „öffentlich“ ist.
Fehler Nr. 3: „Entfernen wir null-Felder, damit das JSON schöner aussieht.“
Schönheit von JSON ist ein fragwürdiger Gewinn, wenn man dabei Bedeutung verliert. null und das Fehlen eines Feldes sind unterschiedliche Signale. Wenn Sie die Antwort „bereinigt“ haben und das Feld mal da, mal weg ist, fangen Clients an, dreimal so viel Schutzcode zu schreiben, wie Sie an Bytes in der Antwort „gespart“ haben.
Fehler Nr. 4: „Fügen wir einen neuen Status hinzu, das ist doch kein Breaking Change.“
Formal ähnelt es einem additive Change, aber viele Clients parsen Enums streng. Im Endeffekt kann eine einzige neue Konstante dazu führen, dass eine alte Mobile-App abstürzt, weil sie diesen Status nicht verarbeiten kann. Das bedeutet nicht „fügen Sie niemals hinzu“, sondern „fügen Sie bewusst hinzu und denken Sie daran, dass Kompatibilität nicht nur von Ihnen abhängt“.
Fehler Nr. 5: „Wir haben nichts am JSON geändert, nur die Logik geändert.“
Wenn sich die Bedeutung eines Feldes oder die Reihenfolge der ausgegebenen Elemente ändert, ist das für den Client oft nicht weniger schmerzhaft als eine Umbenennung. Ein Vertrag ist „was die Antwort bedeutet“, nicht nur „wie sie aussieht“. Daher sollten alle Änderungen, die die Interpretation beeinflussen, als vertragsrelevant betrachtet werden.
GO TO FULL VERSION