CodeGym /Kurse /Spring REST & MVC /Jackson-Annotationen und die DTO-Grenze

Jackson-Annotationen und die DTO-Grenze

Spring REST & MVC
Level 12, Lektion 4
Verfügbar

1. Jackson-Annotationen – Gewürze

Wenn Sie schon einmal versucht haben, eine Suppe mit einer halben Dose Pfeffer zu „reparieren“, verstehen Sie die Idee dieses Abschnitts. Mit Jackson-Annotationen verhält es sich genauso: Sie funktionieren hervorragend, wenn sie punktuell eingesetzt werden, verwandeln sich aber schnell in den Versuch, ein schlecht designtes Datenmodell mit „Aufklebern“ zu korrigieren. Und am Ende erhalten Sie ein DTO, das erschreckender zu lesen ist als ein StackTrace in der Produktion.

Die Kernaussage der heutigen Vorlesung lautet: Zuerst entwerfen wir das DTO und den Vertrag, dann fügen wir einen minimalen Satz an Annotationen hinzu, wenn diese den Vertrag tatsächlich stabiler und verständlicher machen. Nicht umgekehrt.

Nach ein paar gelungenen Annotationen stellt sich diese Versuchung besonders schnell ein: Man möchte damit grundsätzlich jedes JSON-Problem behandeln.

Um nicht nur zu behaupten, stellen wir uns zwei Situationen vor.

Die erste, „gesunde“: Wir haben eine TaskDetailsResponse, bei der wir im Java-Code das Feld etwas kürzer nennen möchten (z. B. assignee), aber im JSON-Vertrag den vom Kunden gewohnten Namen assigneeName behalten wollen. Das ist eine hervorragende Stelle für @JsonProperty – eine kleine, punktuelle Konfiguration, die die API stabiler macht.

Die zweite, „gefährliche“: Wir haben ein universelles TaskDto erstellt, versuchen es sowohl als Request als auch als Response zu verwenden, und fügen dann @JsonAlias, @JsonIgnore, @JsonIgnoreProperties, noch ein paar @JsonProperty hinzu – und plötzlich wird das DTO zu einem „Klumpen Kompromisse“. Es funktioniert zwar, aber es erklärt den Vertrag nicht mehr – es kaschiert das Chaos.

In unserem Task Tracker API (Basis-Paket com.example.tasktracker) ist dies besonders wichtig, da das Projekt bewusst lehrreich und transparent gestaltet ist: Wir möchten, dass jeder Student ein DTO öffnen und verstehen kann, was die API empfängt und zurückgibt, anstatt es anhand eines Haufens von Annotationen „für alle Fälle“ erraten zu müssen.

2. Reihenfolge der Entscheidungen für DTOs und JSON

Wenn man beginnt, aktiv mit JSON zu arbeiten, tappt man leicht in die Falle „lass uns mal eben Jackson nachjustieren“. Aber die richtige Reihenfolge ist die umgekehrte: Zuerst klärt man die Vertragsfragen, dann die Namensfragen, dann die Eingabekompatibilität und erst ganz zum Schluss die „schwere Artillerie“. Dieser Abschnitt handelt davon, wie man eine solche Reihenfolge im Kopf behält, um das DTO nicht in eine Mini-Konfigurationsdatei aus Annotationen zu verwandeln.

Es ist hilfreich, ein einfaches mentales Modell zu haben: DTO ist der Vertrag, Jackson der Übersetzer, Annotationen sind Hinweise an den Übersetzer. Wenn der Vertrag schlecht ist, kann der Übersetzer nichts retten. Er kann nur das, was man ihm gegeben hat, sorgfältig übersetzen.

Nachfolgend finden Sie einen „Entscheidungsbaum“, der sowohl in Studienprojekten als auch in echten Teams gut funktioniert.

flowchart TD
    A[Muss JSON-Verhalten eingestellt werden?] --> B{Problem in der DTO-Form?}
    B -->|Ja| C[DTO neu zusammenstellen: Request/Response getrennt, explizite Felder]
    B -->|Nein| D{Ist das Request oder Response?}

    D -->|Response| E{Wird ein anderer Feldname im JSON benötigt?}
    E -->|Ja| F["@JsonProperty auf dem Feld/Record-Komponente"]
    E -->|Nein| G[So lassen]

    D -->|Request| H{Muss ein alternativer Name akzeptiert werden?}
    H -->|Ja| I["@JsonAlias minimal"]
    H -->|Nein| J[Kanonischer Name ohne Alias]

    J --> K{Was tun mit unbekannten Feldern?}
    I --> K
    K -->|Streng| L[Strenge Politik beibehalten]
    K -->|Tolerant| M["@JsonIgnoreProperties(ignoreUnknown = true)"]

    F --> N{Gibt es interne Felder, die durchsickern könnten?}
    G --> N
    N -->|Ja| O[Besser separates DTO; falls nötig — @JsonIgnore]
    N -->|Nein| P[Fertig]

Beachten Sie die erste Frage: „Problem in der DTO-Form?“. Das ist der Schlüssel. Oft beginnen wir mit Annotationen, aber man sollte mit dem Design beginnen.

Um dies noch „bodenständiger“ zu untermauern, finden Sie hier eine kleine Tabelle, mit welchen Mitteln welche Aufgabe in der Regel gelöst wird.

Aufgabe im Vertrag Reicht in der Regel Warum das ausreicht
Im JSON wird ein anderer Name benötigt als in Java @JsonProperty Dies ist eine direkte und verständliche Möglichkeit, den kanonischen Namen festzulegen
Ein alter/alternativer Feldname im Input muss akzeptiert werden @JsonAlias (im Request-DTO) Hilft, den Input zu lesen, ohne den Output-Vertrag zu ändern
Ein internes/Dienstfeld muss versteckt werden besser ein separates DTO, manchmal @JsonIgnore Man kann es verstecken, aber besser ist es, das Feld gar nicht erst in das öffentliche Modell zu ziehen
Überflüssige Felder im eingehenden JSON streng oder @JsonIgnoreProperties(ignoreUnknown = true) Dies ist eine reine Input-Politik und keine Namensgebung

3. Request-DTO: Kanon und Kompatibilität

Das Request-DTO empfängt als erstes das externe JSON, daher beginnt man hier besonders leicht, „alles mit Annotationen zu heilen“. Um dies zu vermeiden, halten Sie sich an eine kurze Entscheidungsreihenfolge.

  • Ein Feld sollte einen kanonischen Namen im Vertrag haben.
  • Wenn Java-Name und JSON-Name voneinander abweichen müssen, legen Sie den externen Schlüssel über @JsonProperty fest.
  • Wenn vorübergehend ein alter Name desselben Feldes akzeptiert werden muss, fügen Sie @JsonAlias hinzu.
  • Wenn es nicht mehr um den Namen, sondern um das Schicksal überflüssiger Schlüssel geht, ist dies eine separate Entscheidung: strenger Input oder @JsonIgnoreProperties(ignoreUnknown = true).

Ein einfacher nützlicher Test: Nach der Deserialisierung sollte der Service eine normale Eigenschaft sehen und sich nicht an die gesamte Historie alter Client-Namen erinnern. Wenn Alias oder toleranter Input über das Request-DTO hinauszulaufen beginnen, reparieren Sie nicht mehr JSON, sondern einen architektonischen Riss.

4. Response-DTO: Stabiles JSON

Das Response-DTO ist Ihr Aushängeschild. Es sollte nicht raten, was der Client gemeint hat; seine Aufgabe ist es, stabil kanonisches JSON auszugeben.

  • Meistens wird hier nur @JsonProperty benötigt, wenn Java-Name und öffentlicher JSON-Schlüssel voneinander abweichen.
  • @JsonAlias wird fast nie benötigt: Eine Antwort sollte nicht mit verschiedenen Clients in unterschiedlichen Dialekten sprechen.
  • @JsonIgnore oder @JsonIgnoreProperties sind angebracht, wenn sich ein bekanntes dienstliches Attribut in den Java-Typ eingeschlichen hat. Aber wenn viel versteckt werden muss, ist dies nicht mehr „Feinabstimmung“, sondern ein Signal, ein separates Response-DTO zu erstellen.

Ein gutes Response-DTO liest sich wie ein fertiger JSON-Vertrag, selbst ohne Kenntnis des internen Modells. Wenn es ohne eine Reihe von Jackson-Annotationen völlig unverständlich ist, liegt das Problem meist in der DTO-Form, nicht im Fehlen einer weiteren Einstellung.

5. Überannotiertes DTO: Gerüche

Es gibt einen sehr guten Alltagsindikator: Wenn ein DTO schwer zu lesen ist, ist der Vertrag schwer zu erklären. Und wenn der Vertrag schwer zu erklären ist, werden die Clients erst recht ihren Spaß haben (und das ist nicht der Spaß, den wir mögen).

Überannotierung zeigt sich normalerweise nicht in der Anzahl der Annotationen an sich, sondern im Gefühl: „Was bedeutet dieses Feld überhaupt und warum hat es so viele Namensvarianten?“

Schauen wir uns ein Beispiel an, wie man es nicht machen sollte. Ich habe es bewusst maximal unangenehm gestaltet – damit Sie den leichten Drang verspüren, den Laptop zuzuklappen und in den Wald zu gehen. Dieses Gefühl ist nützlich: Es hilft später, guten Code zu schreiben.

import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;

public class BadTaskDto {
    // In JSON heißt das Feld "title", aber aus irgendeinem Grund speichern wir es in text (bereits ein alarmierendes Zeichen)
    @JsonProperty("title")
    // Die Liste der Aliase wurde zu einem „Synonymwörterbuch“: So hört der Vertrag auf, ein Vertrag zu sein
    @JsonAlias({"taskTitle", "name", "caption", "task_name"})
    public String text;

    // Inneren Zustand mit Annotation versteckt, aber allein die Tatsache, dass es im DTO vorhanden ist, riecht nach Vermischung der Rollen
    @JsonIgnore
    public String internalState;
}

Was ist hier falsch, auch wenn es „funktioniert“?

Im Feld text vermischen sich mehrere Bedeutungen: Es ist sowohl „title“ als auch „name“ und „caption“. Wir wissen nicht, ob dies dasselbe oder unterschiedliche Konzepte sind. Wenn es dasselbe ist, warum vier Aliase? Wenn es unterschiedliche Konzepte sind, warum kleben Sie sie in ein Feld? Außerdem ist internalState versteckt, aber die Tatsache, dass es hier ist, deutet darauf hin: Das Datenmodell ist bereits zur Hälfte intern, zur Hälfte extern.

Wie wird dies erwachsen geheilt, aber im Rahmen unseres Kurses: Sie verteilen die Rollen auf verschiedene DTOs. Für die Eingabe – ein Request-DTO (wo es Alias geben kann), für die Ausgabe – ein Response-DTO (wo Alias nicht nötig ist). Und oft werden danach die Annotationen weniger, weil das Modell selbst ehrlicher geworden ist.

6. Grenze der Annotationen: Custom Serializer

In diesem Abschnitt möchte ich eine wichtige Pause einlegen. Wenn man erfährt, dass man mit Annotationen viel erreichen kann, macht man meist den nächsten logischen Schritt: „Wenn die Annotationen nicht ausreichen, schreibe ich meinen eigenen Serializer.“ Das klingt heldenhaft, etwa wie „ich schreibe mein eigenes JSON“, aber in der Praxis bedeutet es oft: Sie komplizieren das Projekt unnötig früh. Ein Custom Serializer/Deserializer ist ein nützliches Werkzeug, aber er ist keine „DTO-Konfiguration“ mehr, sondern Code, der gewartet, getestet und erklärt werden muss.

Ein Custom Serializer ist buchstäblich eine Java-Klasse, die Jackson sagt: „Wenn du diesen Wert in JSON schreibst, mach es so und so.“ Schauen wir uns ein minimales Skelett an. Selbst in seiner einfachen Form ist es bereits länger als @JsonProperty und erfordert eine Registrierung (und die Registrierung ist bereits eine Jackson-Konfiguration auf Anwendungsebene).

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;

import java.io.IOException;

public class TaskStatusSerializer extends JsonSerializer<TaskStatus> {
    @Override
    public void serialize(TaskStatus value, JsonGenerator gen, SerializerProvider p)
            throws IOException {
        // Format explizit festlegen: Enum als String schreiben (z.B. "OPEN"), nicht als Objekt/Zahl
        gen.writeString(value.name());
    }
}

Es sieht nicht schlimm aus, aber jetzt haben Sie neue Fragen: Wo wird dieser Serializer angebunden, für welche DTOs funktioniert er, was passiert beim Rücklesen, wie dokumentiert man das, wie testet man das und warum unterscheidet es sich überhaupt vom Standardverhalten?

In unserem Übungs-REST-Projekt werden die allermeisten Aufgaben einfacher gelöst: @JsonProperty legt den Namen fest, @JsonAlias hilft, Alternativen zu akzeptieren, @JsonIgnore verbirgt Interna, @JsonIgnoreProperties(ignoreUnknown = true) macht den Input tolerant. Das sind vier Schraubenzieher, mit denen man sehr viele Möbel zusammenbauen kann. Ein Custom Serializer ist bereits eine „Werkbank in der Garage“. Sie ist toll, aber Sie möchten sie nicht in die Wohnung schleppen, um ein Tischbein anzuschrauben.

Ein gutes praktisches Kriterium: Wenn Sie einen Custom Serializer nur zum Umbenennen eines Feldes, zum Akzeptieren von zwei Namen im Input oder zum Verstecken eines internen Feldes einsetzen möchten – dann lösen Sie das Problem mit ziemlicher Sicherheit auf viel zu schwere Weise. Annotationen und ein gutes DTO-Design decken diese Fälle viel einfacher und transparenter ab.

Aber auch dieser Ansatz hat seine Grenzen. Sobald JSON nicht mehr vollständig statisch ist – generische Wrapper, Tree-Modelle oder teilweise dynamische Dokumentteile auftauchen – reichen die alleinigen Annotationen auf einem gewöhnlichen DTO nicht mehr aus. Dann sind flexiblere JSON-Werkzeuge gefragt, nicht eine weitere Schicht von @Json* über einer alten Klasse.

7. Typische Fehler bei der Arbeit mit Jackson-Annotationen

Fehler mit Jackson-Annotationen sind meist tückisch, weil der Compiler schweigt, die Anwendung startet und die Probleme erst auf Vertragsebene auftauchen: Der Client versteht Ihr JSON nicht mehr, Tests beginnen zu scheitern, die Dokumentation stimmt nicht mehr mit der Realität überein. Daher ist es sinnvoll, die häufigsten „Stolpersteine“ im Voraus zu kennen und am Klang zu erkennen.

Fehler Nr. 1: Mit Annotationen beginnen, nicht mit der DTO-Form.
Wenn Sie sich bei dem Gedanken ertappen „ich füge jetzt noch eine Annotation hinzu – dann wird es schon gut“, halten Sie inne und fragen Sie: Ist das DTO überhaupt sinnvoll gestaltet? Vielleicht versuchen Sie, mit einer Klasse sowohl Eingabe, Ausgabe als auch internen Zustand zu beschreiben. In diesem Fall werden die Annotationen nur Kosmetik auf einem schlechten Design sein, nicht ein Werkzeug zur Stärkung des Vertrags.

Fehler Nr. 2: @JsonAlias in ein Wörterbuch von Synonymen verwandeln.
Alias ist nützlich, wenn es genau ein bis zwei echte alternative Schlüssel gibt, die dasselbe bedeuten. Wenn der Alias zu einer Liste von fünf Varianten wird (name, title, caption, taskTitle, task_name), hört das DTO auf, ein Vertrag zu sein, und wird zu einem Ratespiel. Der Client beginnt, „wie es gerade kommt“ zu senden, und Sie selbst verlieren den Überblick, was als korrekt gilt.

Fehler Nr. 3: Erwarten, dass @JsonAlias die Antworten beeinflusst.
@JsonAlias wird zum Lesen des eingehenden JSON benötigt. Es sollte nicht Teil der öffentlichen Antwortform werden. Wenn Sie sich dabei ertappen, zu denken „lassen Sie uns manchmal auch in der Antwort assignee ausgeben“, halten Sie inne: Das zerstört bereits die Vertragsdisziplin. Die Antwort sollte kanonisch sein, sonst werden die Clients von Zufälligkeiten abhängig.

Fehler Nr. 4: Die Hälfte des Modells durch @JsonIgnore verstecken, anstatt ein normales Response-DTO zu erstellen.
@JsonIgnore ist ein nützlicher „Überflüssiges nicht zeigen“-Knopf, aber wenn Sie ihn bei jedem zweiten Feld anwenden, bedeutet das fast immer, dass Sie nicht das Richtige nach außen serialisieren. In einer normalen API muss das Domänenmodell nicht mit dem externen JSON übereinstimmen. Wenn viel versteckt werden muss, ist es einfacher und sauberer, ein separates DTO und explizites Mapping zu erstellen.

Fehler Nr. 5: ignoreUnknown = true überall aktivieren, weil „das Leben einfacher macht“.
Ein toleranter Input ist manchmal nötig, aber wenn er standardmäßig „für alle Fälle“ eingeschaltet ist, schlucken Sie Client-Fehler. Ein Tippfehler in einem Feld wird zu einem stillen Problem, das später und an anderer Stelle auftaucht. Viel gesünder ist es, wenn Strenge der Basiszustand ist und Toleranz dort aktiviert wird, wo Sie sie ausdrücklich unterstützen möchten.

1
Aufgabe
Spring REST & MVC, Level 12, Lektion 4
Gesperrt
Getrennte Request/Response DTO für einen Anzeigenentwurf
Getrennte Request/Response DTO für einen Anzeigenentwurf
1
Aufgabe
Spring REST & MVC, Level 12, Lektion 4
Gesperrt
Minimaler Satz von Annotationen für einen Fotoentwurf
Minimaler Satz von Annotationen für einen Fotoentwurf
1
Umfrage/Quiz
Jackson-Verträge, Level 12, Lektion 4
Nicht verfügbar
Jackson-Verträge
Feldnamen und Aliase
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION