1. null in der Antwort als Teil des Vertrags
Wenn Sie gerade erst anfangen, ist es leicht, so zu denken: „Na, null … na gut, das Feld kam nicht … was macht das schon für einen Unterschied, der Client wird schon irgendwie klarkommen“. In der Praxis klärt der Client das natürlich. Nur wird nicht der Code, sondern ein Mensch um 2 Uhr nachts damit klarkommen, wenn die mobile Anwendung auf dem Aufgabenbildschirm abstürzt, weil irgendwo unerwartet null statt [] angekommen ist. Deshalb behandeln wir Werte in Response-DTOs wie klare Signale: Jeder Zustand muss eine klare Bedeutung haben und stabil sein.
In einer REST-API liest der Client (Frontend, mobile App, Integration, Tests, manchmal sogar Ihr eigener Postman) JSON als Vertrag. Ein Vertrag hat „Lexik“ und „Grammatik“. Und genau so wie null, fehlendes Feld und leerer Wert – das sind drei verschiedene Wörter. Wenn Sie sie wie Synonyme verwenden, ergibt sich dasselbe wie in der normalen Sprache: „Ich komme nie oder vielleicht“ – das klingt unbestimmt und verunsichert.
Vier Datenzustände in der Antwort
Um dieses Thema nicht zu einem philosophischen Zirkel „Der Sinn der Leere in JSON“ werden zu lassen, ist es praktisch, vier verschiedene Situationen im Kopf zu behalten. Jede von ihnen wird anders ausgedrückt und hat eine andere Bedeutung. Die gute Nachricht: Wir müssen nichts Kompliziertes erfinden – es reicht, sich einmal zu einigen und die Regeln einzuhalten. Die schlechte Nachricht: Wenn wir uns nicht einigen, wird es sich trotzdem „selbst einigen“, nur zufällig und jedes Mal anders.
Unten finden Sie eine kleine Tabelle, die unser Leitfaden speziell für Response-DTOs in der Task Tracker API sein wird:
| Zustand | Wie sieht es in JSON aus | Was es normalerweise bedeutet | Beispiel aus Task Tracker |
|---|---|---|---|
| null | "assigneeName": null | Das Feld existiert im Vertrag, aber für die konkrete Ressource gibt es keinen Wert | Die Aufgabe hat keinen Bearbeiter |
| Feld fehlt | Das Feld fehlt ganz | Dies ist eine andere Antwortform (anderes DTO) oder das Feld ist nicht Teil dieser Repräsentation der Ressource | description fehlt in der Summary-Antwort |
| Leerer Wert | "tags": [], "items": [] | Der Wert ist vorhanden, er ist korrekt, nur „leer“ | Tag-Liste ist leer, Aufgabenliste auf der Seite ist leer |
| Server-seitiger Standardwert | "archived": false, "priority": "MEDIUM" | Der Server teilt explizit mit, welchen Wert er als aktuell/angewendet betrachtet | Aufgabe ist nicht archiviert; Priorität ist standardmäßig gesetzt |
Der Sinn dieser Tabelle besteht nicht darin, dass Sie sie wie ein Gedicht auswendig lernen. Der Sinn ist, dass Sie jedes Mal eine bewusste Entscheidung treffen und sie nicht dem Zufall überlassen, wie „Jackson es halt gemacht hat“.
2. null im Response-DTO
null in der Antwort ist weder böse noch gut. Es ist einfach ein separater Wert mit der Bedeutung: „Dieses Feld hat keinen Wert“. Probleme beginnen, wenn null „aus Gewohnheit“ auftaucht und wir selbst nicht sagen können, was es bedeutet. Deshalb werden wir in unserem Projekt festlegen: null ist sinnvoll für skalare optionale Felder, bei denen es tatsächlich keinen Wert geben kann und der Client dies sehen muss. Für Sammlungen ist null dagegen meist eher schädlich als nützlich: Der Client muss zusätzliche Prüfungen durchführen.
Um diesen Gedanken nicht auf ein großes DTO zu verallgemeinern, nehmen wir eine verkürzte Details-Form: Hier geht es genau um den Unterschied zwischen null und []. Beispielsweise kann eine Aufgabe keinen Bearbeiter haben (assigneeName), und das ist eine normale fachliche Bedeutung: Die Aufgabe ist „in der Warteschlange“, niemand ist zugewiesen. Dann wird null ehrlich gelesen.
import java.util.List;
// DTO für die Detail-Antwort: hier ist wichtig, dass assigneeName null sein kann,
// und tags eine Liste ist (idealerweise auch wenn leer).
public record TaskDetailsResponse(
String id,
String title,
String assigneeName, // null bedeutet: Bearbeiter nicht zugewiesen
List<String> tags // Tag-Liste: besser [] statt null
) {}
Und so könnte dies auf Objektebene aussehen:
import java.util.List;
// Beispiel: Bearbeiter nicht zugewiesen (assigneeName = null),
// aber die Tag-Liste existiert und ist einfach leer.
TaskDetailsResponse dto = new TaskDetailsResponse(
"3fa85f64-5717-4562-b3fc-2c963f66afa6",
"Write API docs",
null,
List.of()
);
Wenn man das in JSON verwandelt (ungefähr), erhält man:
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"title": "Write API docs",
"assigneeName": null,
"tags": []
}
Hier sind sofort zwei unterschiedliche Bedeutungen erkennbar: Bei assigneeName gibt es keinen Wert, während tags eine Liste ist, nur eben leer.
Nun ein wichtiger Punkt, der oft den Vertrag zerstört: Der leere String "" ist nicht gleich null. Wenn Sie anfangen, "assigneeName": "" zurückzugeben, wird dem Client unklar, was das bedeutet. „Der Bearbeiter existiert, aber sein Name ist leer“? „Der Server konnte den Bearbeiter nicht lesen“? „Das ist ein Datenfehler“? In Antworten ist es besser, ein klares Signal zu haben.
In der Task Tracker API werden wir mit ziemlicher Sicherheit diese Semantik haben wollen: Ein optionaler String fehlt als null, nicht als leerer String. Ja, „leerer String“ ist auch ein Wert, aber er wird allzu oft zu einem Müllzustand, der von allen Clients schwer einheitlich interpretiert werden kann.
3. Fehlen des Feldes
Das Fehlen eines Feldes in JSON ist kein „besonderer null“. Es ist ein anderer Zustand: „In dieser Antwortform gibt es das Feld nicht“. Und der gesündeste Weg, das Fehlen eines Feldes zu erreichen, ist nicht die Serialisation zu manipulieren, sondern das richtige DTO auszuwählen. Wir haben bereits vereinbart, dass Summary und Details unterschiedliche Darstellungsformen der Ressource sind. Das bedeutet: Wenn description in der Liste nicht benötigt wird, sollte es nicht „mal auftauchen, mal verschwinden“. Es sollte einfach nicht im Summary-DTO enthalten sein.
// Summary-DTO: nur Felder, die in der Liste benötigt werden.
public record TaskSummaryResponse(
String id,
String title,
String status
) {}
import java.util.List;
// Details-DTO: erweiterte Form (daher gibt es description und tags).
public record TaskDetailsResponse(
String id,
String title,
String description,
String status,
List<String> tags
) {}
Von außen sieht das sehr klar aus: /tasks gibt eine Liste von Aufgaben mit den minimal benötigten Feldern zurück, /tasks/{id} gibt ein umfassenderes Objekt zurück. Der Client verschwendet keine Ressourcen auf das Parsen „überflüssiger“ Felder, und Sie müssen keinem Integrator erklären, warum description in der Liste „manchmal null, manchmal ganz fehlt“.
Es gibt die Versuchung, es anders zu machen: „Lass uns ein DTO nehmen und description manchmal einfach weglassen“. Meistens endet das damit, dass der Client Code mit zwei Zweigen schreibt: „wenn Feld vorhanden – mach A, wenn Feld fehlt – mach B“, und sehr schnell taucht die Frage auf: „Und wann ist es vorhanden?“. Wenn Sie keine definitive Antwort darauf haben, dann haben Sie den Vertrag selbst nicht festgelegt.
In unseren Vereinbarungen klingt das einfach: Das Fehlen eines Feldes betrifft die Form der Antwort (response shape), nicht einen bestimmten Wert. Daher ist ein fehlendes Feld meistens ein „anderes DTO“, keine „dynamische Serialisationsmagie“.
Es gibt eine praktische Einschränkung. Diese Semantik funktioniert nur, wenn die Antwort-Serialisierung tatsächlich null-Felder im JSON belässt. Wenn das Projekt global null entfernt, fallen explizites null und „Feld fehlt“ in dasselbe Signal. Das ist eine andere Vertragsentscheidung und muss bewusst getroffen werden, nicht zufällig durch eine allgemeine Serialisierungseinstellung. Für Anfragekörper, insbesondere in Patch-ähnlichen Szenarien, null und fehlendes Feld können bereits andere Bedeutungen haben; hier legen wir speziell den Antwortvertrag fest.
4. Leere Werte: [] statt null
Leere Werte in der Antwort sind in Ordnung. Mehr noch: Ein leerer Wert ist oft besser als null, weil er die Notwendigkeit zusätzlicher Verzweigungen auf Client-Seite entfallen lässt. Besonders deutlich wird das bei Sammlungen: Tag-Liste, Kommentarliste, Listenelemente einer Seite. Wenn Sie null anstelle von [] zurückgeben, muss der Client zusätzlichen Code schreiben, und das bedeutet mehr Stellen, an denen Fehler auftreten können.
Und das betrifft nicht nur tags, sondern auch unser PagedResponse<T>: items sollte eine Liste sein. Leer – ja. null – fast nie.
import java.util.List;
// Paging-Antwort: items ist immer eine Liste (auch wenn keine Ergebnisse).
public record PagedResponse<T>(
List<T> items, // [] statt null
int page,
int size,
long totalElements,
int totalPages,
String sort
) {}
Ein Beispiel für eine Antwort für die Situation „nichts gefunden“ (z. B. Filter nach Status liefert keine Ergebnisse) ist völlig legitim und sollte vorhersagbar aussehen:
import java.util.List;
// Nichts gefunden – das ist KEIN Fehler: einfach items = [] und totalElements = 0.
PagedResponse<String> response = new PagedResponse<>(
List.of(),
0,
20,
0,
0,
"updatedAt,desc"
);
Auf JSON-Ebene sieht das ungefähr so aus:
{
"items": [],
"page": 0,
"size": 20,
"totalElements": 0,
"totalPages": 0,
"sort": "updatedAt,desc"
}
Beachten Sie: Eine leere Liste ist kein Fehler und keine „fehlende Ressource“. Es ist ein konkreter, korrekter Wert: „Es gibt keine Ergebnisse“.
Nun die Frage: Wie stellt man [] sicher, wenn im Inneren plötzlich null auftaucht? Der einfachste, lehrreich durchsichtige Weg ist, im Mapper eine null-Sicherung durchzuführen, denn der Mapper ist genau dafür verantwortlich, die „öffentliche Form“ zu liefern.
import java.util.List;
import java.util.Objects;
// Vertragssicherung: wenn in der Domain tags = null, geben wir trotzdem eine Liste nach außen.
List<String> safeTags = Objects.requireNonNullElse(task.getTags(), List.of());
Diese Zeile löst zwei Probleme: Ihr Response-DTO wird nicht mit null-Sammlungen „infiziert“, und der Client erhält eine stabile Form: Eine Liste ist immer eine Liste.
5. Defaults und einheitliche Semantik
Server-seitiger Standardwert: das Ergebnis zurückgeben
Ein server-seitiger Standardwert ist eine Situation, in der der Server es „verschweigen“ könnte, aber besser explizit einen Wert zurückgibt. Besonders nützlich ist das für Statusfelder, die der Client ohne Rätselraten verstehen muss. In unserem Projekt ist ein guter Kandidat archived. Ja, im Domänenmodell kann die Wahrheit in status liegen, aber für den Client ist es praktisch, ein explizites Flag zu haben. Und wenn das Flag im Vertrag existiert, sollte es immer vorhanden und explizit sein: true oder false.
// Beispiel DTO, wo der boolean-Flag immer vorhanden sein sollte: true/false ohne null.
public record TaskStateResponse(
String id,
String status,
boolean archived
) {}
Der Sinn von archived=false ist nicht, dass „der Standardwert in Java für boolean false ist“. Der Sinn ist, dass der Server dem Client sagt: „Im aktuellen Zustand ist die Aufgabe nicht archiviert“. Das ist ein wichtiger Teil des Ressourcenverständnisses, kein technisches Detail.
Ebenso kann der Server-Standardwert für Dinge wie priority nützlich sein. Angenommen (vereinfacht), dass der Standardpriorität im System MEDIUM ist. Selbst wenn der Client bei der Aufgabenerstellung keine Priorität übermittelt hat, geben Sie in der Antwort auf Erstellung oder Abruf bereits den konkreten Wert zurück. Der Client muss nicht raten, welche Logik der Server angewendet hat.
import java.util.Objects;
// Wenn der Server einen Default angewendet hat – nützlich, den bereits berechneten Wert nach außen zurückzugeben.
String priority = Objects.requireNonNullElse(task.getPriority(), "MEDIUM");
Wichtig: Das ist kein Aufruf, priority als String im Domänenmodell zu halten. Es demonstriert die Idee: Wenn der Server eine Standardregel angewendet hat, sollte der Client das Ergebnis sehen.
Wie man die Regeln in der API verankert
Wenn Sie eine API entwerfen, ist nicht nur die „Richtigkeit“ jeder einzelnen Entscheidung wichtig, sondern auch ein einheitlicher Stil. Selbst eine gute Entscheidung, die an einer Stelle getroffen wird, und die gegenteilige an einer anderen, erzeugt ein Gefühl von Chaos. Daher können wir für die Task Tracker API einfache und klare Regeln festlegen: Sammlungen in der Antwort kommen immer als Listen (einschließlich leerer), optionale skalare Felder können null sein, und „Feld fehlt“ wird durch unterschiedliche DTOs erreicht, nicht durch zufällige Serialisierungseinstellungen. Das gibt dem Client Vorhersagbarkeit und reduziert die Menge an defensivem Code.
Unten sehen Sie einen kleinen Ausschnitt aus TaskMapper, der zwei Prinzipien gleichzeitig zeigt: tags werden vor null geschützt, während assigneeName als null belassen wird, wenn es im Modell keinen gibt.
import java.util.List;
import java.util.Objects;
public class TaskMapper {
public TaskDetailsResponse toDetails(Task task) {
// Vertrag: nach außen sollen tags eine Liste sein, auch wenn in der Domain null.
List<String> tags = Objects.requireNonNullElse(task.getTags(), List.of());
// assigneeName belassen wir wie es ist: null = "nicht zugewiesen".
return new TaskDetailsResponse(task.getId(), task.getTitle(), task.getAssigneeName(), tags);
}
}
Das DTO ist hier verkürzt, und das ist in Ordnung: Uns geht es nicht um die Vollständigkeit der Antwort, sondern um das Prinzip selbst. Der Mapper ist der Ort, an dem wir die öffentliche Semantik der Antwort „zusammenbauen“. Wenn wir wollen, dass tags nicht null sind, stellen wir das dort sicher, anstatt darauf zu hoffen, dass „es nie null sein wird“ (Spoiler: Es wird).
Und noch ein wichtiger Punkt: Wenn Sie entscheiden, dass tags immer eine Liste ist, halten Sie diese Linie überall in der API ein. Geben Sie nicht an einem Endpoint tags: null und an einem anderen tags: [] zurück. Aus Sicht des Clients sind das zwei verschiedene Verträge, selbst wenn die Java-Klasse dieselbe ist. Die Stabilität des Vertrags beginnt nicht mit OpenAPI oder Tests, sondern mit solchen „kleinen“ (eigentlich nicht) Vereinbarungen.
6. Typische Fehler: null und Leere in Antworten
An diesem Punkt könnte bei Ihnen das Gefühl aufkommen: „Wow, wir diskutieren über Feinheiten, anstatt Features zu coden“. Das ist ein normales Gefühl, denn der API-Vertrag besteht nicht nur darin, „einen Endpoint zu erstellen“, sondern auch darin, „ihn vorhersagbar zu machen“. Die meisten Probleme mit null und leeren Werten sehen zum Zeitpunkt der Controller-Erstellung nicht wie Probleme aus. Sie sehen wie Probleme zum Zeitpunkt der Integration aus, wenn der Client bereits existiert und ein stabiles Verhalten erwartet.
Fehler Nr. 1: null statt einer leeren Liste zurückgeben.
Meistens passiert das versehentlich: Irgendwo im Domänenmodell waren tags null, Jackson hat null brav serialisiert, und der Client erhält plötzlich keine Liste. Das Ergebnis ist normalerweise nur in der Theorie lustig: Der Client schreibt for (tag : tags) und fängt eine NPE. Die Lösung ist eine einfache Regel: Sammlungen in Antworten sind immer Listen, nicht null, und das wird im Mapper sichergestellt.
Fehler Nr. 2: „Dynamisches Fehlen des Feldes“ statt verschiedener DTOs.
Wenn description mal da ist, mal nicht, muss der Client Code mit Rätselraten schreiben: „Was könnte das bedeuten?“. Wenn ein Feld fehlt, sollte das Teil der Antwortform sein. In unserem Projekt wird das durch verschiedene DTOs erreicht: Summary enthält keine Details, Details enthält sie. Das ist einfacher für den Client und ehrlicher als Vertrag.
Fehler Nr. 3: null und leeren String als zwei Wege mischen, um „kein Wert“ zu sagen.
"" und null sind unterschiedliche Zustände. Wenn Sie beide Varianten zulassen, muss der Client beide unterstützen. Normalerweise wird der leere String zu Müll, weil dann Leerzeichen, „N/A“, „unknown“ und andere Schönheiten auftauchen. Für optionale Strings wählt man besser ein Signal – meistens null.
Fehler Nr. 4: Wichtige Zustände hinter „Defaults“ verstecken und nicht explizit zurückgeben.
Wenn das Flag archived im Vertrag steht, dann ist archived=false eine nützliche Information, nicht „ach, das ist halt der Standard in Java“. Genauso ist es bei angewendeten Server-Regeln (z. B. Standard-Priorität) nützlich, den endgültigen Wert in der Antwort zurückzugeben, damit der Client den tatsächlichen Zustand der Ressource sieht, anstatt zu raten, was „der Server wohl gemacht hat“.
Fehler Nr. 5: Die Semantik eines Feldes in verschiedenen Endpoints ändern.
Die giftigste Variante von Instabilität ist, wenn in einem Endpoint das Feld assigneeName als null kommt, in einem anderen verschwindet und in einem dritten zu "" wird. Alle drei Fälle scheinen „ähnlich“ zu sein (Bearbeiter nicht vorhanden), aber für den Client sind es drei verschiedene Verträge und drei verschiedene Verarbeitungszweige. Wenn Sie sich für eine Semantik entschieden haben, halten Sie sie in der gesamten API einheitlich.
GO TO FULL VERSION