CodeGym /Kurse /Spring REST & MVC /record für DTO: wann es sinnvoll ist

record für DTO: wann es sinnvoll ist

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

1. Woher kommt record und die Rolle von DTO

Wenn Sie jemals ein DTO in „klassischem“ Java geschrieben haben, kennen Sie diesen Schmerz: Sie haben drei Felder, aber es fühlt sich an, als ob Sie ein Raumschiff aus einem Baukasten bauen. Felder, Konstruktor, Getter, equals/hashCode, toString… und schon haben Sie vergessen, warum Sie die Datei überhaupt geöffnet haben. record entstand als Versuch von Java zu sagen: „Okay, wir haben verstanden, Sie haben nicht davon geträumt, Ihr Leben lang getTitle() zu schreiben.“

DTOs (insbesondere Response-DTOs) sind sehr oft „data-only“ Objekte: Sie sollten kein komplexes Verhalten haben, ihren Zustand nach der Erstellung nicht ändern und im Idealfall wie eine Struktur lesbar sein. In solchen Fällen ermöglicht record, den Vertrag kürzer, klarer und mit geringerer Wahrscheinlichkeit eines Fehlers im „technischen Boilerplate“ zu beschreiben.

Stellen Sie sich vor, wir brauchen ein kleines Antwort-DTO für eine Aufgabenliste:

import com.example.tasktracker.domain.model.TaskStatus;

// DTO-Antwort: ein "Schnappschuss" der Daten, der sich bequem in JSON serialisieren lässt
public record TaskSummaryResponse(
    String id,        // Aufgabenkennung
    String title,     // kurzer Titel
    TaskStatus status // Status aus dem Domänenmodell/Enum
) {}

Das ist bereits fast eine Dokumentation. Sie sehen den Header und verstehen sofort: „Okay, in der Zusammenfassung hat die Aufgabe id, title, status.“ Keine versteckten Felder, keine „plötzlichen Setter“ und keine Überraschungen.

2. Was record generiert

Es ist wichtig, record nicht als „magische JSON-Klasse“ oder „besonderen Typ für Spring“ zu betrachten. Es ist immer noch ein Java-Typ, nur mit strengen Regeln und automatischer Generierung des Standard-Boilerplates. record hat eine feste Menge von Komponenten (die Felder), und Java erstellt automatisch für Sie den Konstruktor, Zugriffsmethoden, equals/hashCode und toString. Außerdem verhält sich record fast immer wie ein „unveränderliches“ Objekt: Nach der Erstellung können Sie die Komponenten nicht ändern – keine Setter.

Lassen Sie es uns an einem super-einfachen Beispiel ohne Spring und ohne API zeigen:

// Einfacher record: Java generiert automatisch Konstruktor, equals/hashCode, toString und die Methoden x(), y()
public record Point(int x, int y) {}

Das ist (sehr grob) äquivalent zu einer Klasse mit zwei final Feldern, Konstruktor, x(), y(), equals/hashCode, toString. Und ja, die Zugriffsmethoden bei record heißen nach dem Namen der Komponente, nicht getX().

Man kann die Vorteile „live“ sehen:

public class Demo {
    public static void main(String[] args) {
        // Zwei Objekte mit denselben Komponenten
        Point a = new Point(1, 2);
        Point b = new Point(1, 2);

        // toString() wird automatisch generiert
        System.out.println(a);            // Point[x=1, y=2]

        // equals/hashCode werden ebenfalls automatisch generiert und vergleichen die Komponenten
        System.out.println(a.equals(b));  // true
    }
}

Das mag wie eine Kleinigkeit erscheinen, aber in der Backend-Entwicklung gibt es Hunderte solcher „Kleinigkeiten“. Und wenn es zwanzig, dreißig, fünfzig DTOs werden, werden Sie zu schätzen wissen, dass Ihr Code nicht mehr wie das Abschreiben eines Telefonbuchs aussieht.

3. record für Response-DTOs in Task Tracker API

Wenn wir eine REST-API entwerfen, werden Response-DTOs normalerweise im Service/Mapper als fertiger „Schnappschuss“ von Daten erstellt und sollten danach nicht mehr geändert werden. Wir wollen keine Situation, in der jemand ein DTO erstellt und dann „für alle Fälle“ ein Feld per Setter nachträgt und das JSON plötzlich anders wird. Genau deshalb eignet sich record sehr natürlich für die Rolle von Response-DTOs: Es hilft, den Vertrag stabil und vorhersagbar zu halten.

Nehmen wir unser TaskSummaryResponse. Im Controller geben wir das DTO zurück (und Jackson 3 macht JSON):

import com.example.tasktracker.api.dto.response.TaskSummaryResponse;
import com.example.tasktracker.domain.model.TaskStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
class DemoController {

    @GetMapping("/api/v1/demo-task")
    TaskSummaryResponse demo() {
        // Wir geben ein DTO-Objekt zurück: Spring + Jackson verwandeln es dann in JSON
        return new TaskSummaryResponse("t-1", "Write docs", TaskStatus.TODO);
    }
}

Die Pipeline selbst ändert sich hier nicht: Der Controller gibt immer noch ein Objekt zurück, und Jackson schreibt JSON an der Webschnittstelle. Für record ist etwas anderes wichtig – der Vertrag wird kürzer und nach der Erstellung des DTO möchte man es nicht mehr „versehentlich mit einem Setter nachbessern“ und kann es auch nicht.

Das macht sich besonders bei Response-DTOs auf Summary/Detail-Ebene bemerkbar: Sie erstellen einmal einen Schnappschuss der Daten und schleppen dann keinen veränderlichen Container mit sich herum, nur um drei Felder nach außen zu geben.

4. Jackson 3 und record in JSON

Für Jackson gibt es hier kein besonderes Ritual: record ist ein normaler Java-Typ mit festen Komponenten. Bei der Serialisierung wird die Komponente title einfach zum JSON-Schlüssel "title", und beim Lesen des eingehenden JSON verwendet Jackson den kanonischen Konstruktor von record. Daher ändert record nicht die Pipeline selbst, es macht die Form des DTO nur strikter und kürzer.

Daher kann auch ein Request-DTO ein record sein, wenn es für Sie praktisch ist:

// Request DTO: Jackson erstellt es über den kanonischen Konstruktor des records
public record TaskCreateRequest(
    String title,
    String description
) {}

Und der Controller:

import com.example.tasktracker.api.dto.request.TaskCreateRequest;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
class TaskCreateDemoController {

    @PostMapping("/api/v1/demo-create")
    void create(@RequestBody TaskCreateRequest request) {
        // Bei record-accessor gibt es keinen get-Präfix: wir verwenden title(), description()
        System.out.println(request.title()); // zum Beispiel: "Write docs"
    }
}

Jackson liest das JSON, erstellt TaskCreateRequest(title, description) und übergibt es als fertiges Objekt an die Controller-Methode.

Hier ist es wichtig, zwei record-Besonderheiten zu sehen: Jackson verwendet den kanonischen Konstruktor, und der Datenzugriff erfolgt über title() und description(), nicht über getTitle() und getDescription().

Es gibt eine Feinheit, die für Anfänger oft nicht offensichtlich ist: record eignet sich hervorragend, wenn ein DTO vollständig erstellt wird. Wenn Sie erwarten, dass das Objekt „nach und nach zusammengebaut“ wird (wie Möbel von IKEA, nur ohne den lästigen Inbusschlüssel), ist eine Klasse mit Settern manchmal psychologisch einfacher. Das bedeutet nicht, dass record „schlecht“ für Request-DTOs ist – es bedeutet, dass Sie bewusst die Form wählen sollten, anstatt einen Glaubenskrieg „record immer“ vs. „record nie“ zu führen.

5. record und Kollektionen

Es gibt eine lustige Falle: record sieht unveränderlich aus, kann aber veränderliche Objekte im Inneren enthalten. Zum Beispiel List<String>. Die Komponente tags kann nicht neu zugewiesen werden (kein Setter), aber die Liste selbst kann geändert werden: tags.add(...). Und schon haben Sie ein „unveränderliches DTO“, das sich nach der Erstellung plötzlich ändert. Das ist nicht mehr lustig, wenn Sie einen Bug haben „Warum hat die Antwort plötzlich neue Tags?“

Daher macht man für DTOs mit Kollektionen (insbesondere Response-DTOs) oft eine schützende Kopie im Konstruktor des record:

import java.util.List;

public record TaskTagsResponse(
    String taskId,
    List<String> tags
) {
    public TaskTagsResponse {
        // Wir schützen uns vor null und vor veränderlichen Listen, die von außen geändert werden könnten
        tags = (tags == null) ? List.of() : List.copyOf(tags);
    }
}

Jetzt, selbst wenn jemand eine ArrayList übergibt, speichern wir im Inneren eine sichere Kopie und geben nach außen eine unveränderliche Liste zurück. Testen wir die Idee:

import java.util.ArrayList;
import java.util.List;

public class TagsDemo {
    public static void main(String[] args) {
        List<String> src = new ArrayList<>();
        src.add("java");

        // Wir übergeben eine veränderliche Liste...
        TaskTagsResponse dto = new TaskTagsResponse("t-1", src);

        // ... und ändern sie nach der Erstellung des DTO
        src.add("spring");

        // Im DTO ändert sich die Liste nicht, weil intern copyOf(...) gespeichert ist
        System.out.println(dto.tags()); // [java]
    }
}

Das ist eine sehr praktische Sache speziell für API-Verträge: Sie möchten, dass der „Antwort-Schnappschuss“ stabil ist. record hilft, aber nur, wenn Sie daran denken, dass darin immer noch normale Java-Objekte mit eigenem Leben stecken können.

6. Klasse für DTO: wo es bequemer ist

Es gibt einen Mythos, dass Sie kein „moderner Java-Entwickler“ sind, wenn Sie record nicht verwenden. Das ist ungefähr so wie der Mythos „Ein echter Programmierer trinkt Kaffee ohne Zucker und debuggt mit dem Blick“. In der Praxis ist die Wahl der DTO-Form eine Frage der Lesbarkeit, der Einfachheit der Wartung und der Kompatibilität mit Ihrer Arbeitsweise.

Eine normale Klasse gewinnt manchmal aus ganz bodenständigen Gründen. Zum Beispiel, wenn Sie möchten, dass die DTO-Struktur für Anfänger so vertraut wie möglich ist: Felder, leerer Konstruktor, Getter/Setter. Das ist besonders bei Request-DTOs auffällig, weil ein Request oft in der Nähe der tatsächlichen Benutzereingabe lebt und es manchmal bequemer ist, explizit „Feld + Setter“ zu sehen, als einen riesigen record-Header mit zehn Komponenten.

Hier ist ein typisches „klassisches“ Request-DTO:

public class TaskCreateRequest {
    // Die Felder werden "schrittweise" gefüllt (z.B. von Jackson über Setter)
    private String title;
    private String description;

    // Wird für einige Deserialisierungs-/Framework-Szenarien benötigt
    public TaskCreateRequest() {
    }

    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }

    // Getter/Setter für description wurden absichtlich weggelassen, um das Beispiel kurz zu halten
}

Ja, es fehlt der Getter/Setter für description (ich habe den Code absichtlich kurz gelassen). Aber die Idee ist klar: Ein solches DTO liest sich wie ein „Container für Daten, der gefüllt wird“. Es ist vielen vertraut, und das ist manchmal ein Plus, besonders wenn das Team sich noch an record und „data-only“ Modelle gewöhnt.

Es gibt noch eine wichtige Faustregel: Ein DTO sollte sich nicht in ein Domänenmodell verwandeln. Wenn Sie anfangen, in einem record oder in einer Klasse komplexes Verhalten hinzuzufügen, Methoden wie „Status neu berechnen“, „Übergänge prüfen“, „sich selbst im Repository speichern“, vermischen Sie die Schichten. Für Verhalten gibt es interne Services und das Domänenmodell, und DTO ist die externe Datenform. record macht diesen Gedanken nur etwas „stahlbeton“: Es sagt sozusagen „Ich bin Daten, fasst mich nicht an“.

Unten ist eine kleine Tabelle, die hilft, die DTO-Form ohne Philosophie und ohne Flamewars zu wählen:

Situation im Projekt record ist meist passend Klasse ist meist passend
Response-DTO, wird sofort vollständig erstellt und ändert sich nicht Ja, ideal Möglich, aber viel Boilerplate
DTO ist kurz (2–6 Felder), Lesbarkeit erwünscht Ja Geht auch
DTO ist sehr lang und der Header wird zum Monstrum Manchmal ja, aber über Dekomposition nachdenken Manchmal ja, um die Signatur nicht zu überladen
Schrittweises Füllen, Mutation, Setter nötig Normalerweise nein Ja
Ungewollte Änderungen am DTO nach Erstellung minimieren Ja Nur, wenn Sie diszipliniert keine Setter hinzufügen

Beachten Sie: Die Tabelle bewertet nicht die „Richtigkeit“, sondern die Bequemlichkeit. Im Übungsprojekt werden wir beide Ansätze dort verwenden, wo sie den Code verständlicher machen, nicht „modischer“.

7. Mini-Refactoring: DTO auf record

Damit das Thema nicht nur Theorie bleibt, machen wir ein kleines, aber sehr realistisches Refactoring in unserer Task Tracker API. Wir nehmen ein Response-DTO und wandeln es in einen record um. Das ist eine minimale Änderung, die normalerweise die Architektur nicht bricht, aber sofort das Rauschen im Code reduziert und den Vertrag lesbarer macht.

DTO als record

Datei: com.example.tasktracker.api.dto.response.TaskSummaryResponse

import com.example.tasktracker.domain.model.TaskStatus;

public record TaskSummaryResponse(
    String id,
    String title,
    TaskStatus status
) {}

Mapper bleibt einfach

Datei: com.example.tasktracker.api.mapper.TaskMapper

import com.example.tasktracker.api.dto.response.TaskSummaryResponse;
import com.example.tasktracker.domain.model.Task;

public class TaskMapper {

    public TaskSummaryResponse toSummary(Task task) {
        return new TaskSummaryResponse(task.getId(), task.getTitle(), task.getStatus());
    }
}

Controller ändert sich kaum

Datei: com.example.tasktracker.api.controller.TaskController

import com.example.tasktracker.api.dto.response.TaskSummaryResponse;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
class TaskController {

    @GetMapping("/api/v1/tasks")
    List<TaskSummaryResponse> list() {
        return List.of(); // Platzhalter: Service und Repository haben Sie bereits aus den letzten Tagen
    }
}

Beachten Sie einen psychologisch wichtigen Punkt: Der Wechsel zu record erfordert kein „Projekt umschreiben“. Sie ändern die Form des DTO, und alles funktioniert nach denselben Regeln weiter: Der Controller gibt ein Objekt zurück, Jackson serialisiert JSON, der Client erhält eine Antwort.

Das Einzige, woran man „plötzlich“ denkt, ist der Zugriffsstil auf die Felder des record. Wenn Sie irgendwo im Code dto.getId() geschrieben haben, wird es jetzt dto.id() heißen. Das ist kein Nachteil, sondern eine Besonderheit. Im Java-Ökosystem ist das bereits zur Norm geworden, aber die ersten zwei Tage kann das Gehirn aus Gewohnheit versuchen, get...() zu schreiben – das heilt man mit Übung, wie fast alles beim Programmieren.

8. Typische Fehler bei der Wahl zwischen record und Klasse für DTO

Fehler Nr. 1: „Wir wandeln alles in record um, weil es modern ist.“
record ist ein tolles Werkzeug, aber es muss nicht das einzige sein. Das interne Domänenmodell hat oft Verhalten und einen Lebenszyklus, und DTOs sind manchmal bequem als vertraute Klassen. Wenn Sie die Wahl zur Ideologie machen, kämpfen Sie nicht gegen Code-Probleme, sondern gegen die Realität.

Fehler Nr. 2: Vergessen, dass record andere Accessor-Methoden hat (id() statt getId()).
Das ist eine Kleinigkeit, aber sie fängt Anfänger am häufigsten. Nach der Umstellung eines DTO auf record wird der Compiler sich über alte Getter beschweren. Die gute Nachricht: Der Compiler beschwert sich ehrlich und sofort, nicht „manchmal in der Produktion“.

Fehler Nr. 3: record als vollständig unveränderlich betrachten und nicht an Kollektionen denken.
record schützt Sie vor dem Neuzuweisen einer Komponente, aber nicht vor der Veränderbarkeit des Objekts innerhalb der Komponente. Wenn in einem record eine List gespeichert ist, kann sie von außen geändert werden. Für Response-DTOs ist das besonders gefährlich: Sie dachten, Sie hätten die „Antwort bereits erstellt“, aber sie ändert sich. Die Lösung ist List.copyOf(...) im Konstruktor.

Fehler Nr. 4: record als Ort für Geschäftslogik verwenden.
Manchmal kommt die Versuchung, einem record eine Methode isArchived() oder eine „intelligente“ Statusberechnung hinzuzufügen. Kleine „bequeme“ Methoden sind manchmal in Ordnung, aber sobald ein DTO beginnt, Entscheidungen zu treffen und das Verhalten des Systems zu ändern, haben Sie den Web-Vertrag und die Geschäftsschicht vermischt. Ein DTO sollte ein externes Datenmodell bleiben, kein Mini-Service im Disguise.

Fehler Nr. 5: record für Request-DTOs „ohne Rücksicht auf Lesbarkeit und Wartbarkeit“ verwenden.
Technisch kann ein Request-DTO ein record sein, und Jackson 3 kann damit umgehen. Aber wenn Ihr Team aus Anfängern besteht, denen „Felder + Setter“ leichter fallen, kann eine Klasse pädagogisch durchsichtiger sein. Es gibt keine Schande: Ziel ist ein lesbarer Vertrag und ein vorhersagbares API-Verhalten, nicht ein Wettbewerb „wer weniger Zeilen geschrieben hat“.

1
Aufgabe
Spring REST & MVC, Level 11, Lektion 4
Gesperrt
Response DTO als record
Response DTO als record
1
Aufgabe
Spring REST & MVC, Level 11, Lektion 4
Gesperrt
Gewöhnliche Klasse als Eingabe und Record als Ausgabe
Gewöhnliche Klasse als Eingabe und Record als Ausgabe
1
Umfrage/Quiz
JSON-Vertrag, Level 11, Lektion 4
Nicht verfügbar
JSON-Vertrag
DTOs, Datumstypen und enum
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION