CodeGym /Kurse /Spring REST & MVC /Entwurf von TaskPatchRequest

Entwurf von TaskPatchRequest

Spring REST & MVC
Level 13, Lektion 1
Verfügbar

1. TaskPatchRequest als separates DTO

Wenn man zum ersten Mal mit PATCH in Berührung kommt, möchte man es oft „schnell“ machen: ein bereits vorhandenes TaskCreateRequest oder sogar TaskDetailsResponse nehmen, ein paar Felder entfernen und sagen: „Na gut, ich habe ein Patch-DTO.“ Das ist ein völlig normaler menschlicher Impuls: Das Gehirn liebt Wiederverwendung. Aber in einem kontraktbasierten API wird Wiederverwendung oft zu einem versteckten Sinnverlust: Das DTO beginnt in mehreren Szenarien gleichzeitig zu leben, und Sie verlieren die Möglichkeit, ehrlich zu beschreiben, was dem Kunden erlaubt ist und was nicht.

Die Hauptidee hier ist einfach: TaskPatchRequest ist ein Transportmodell für partielle Änderungen. Es hat einen anderen Zweck als Create/Update-Modelle. Create sagt: „Hier sind die Daten zum Erstellen einer Ressource.“ Detail Response sagt: „So sieht die Ressource nach außen aus.“ Patch sagt: „Hier sind die Teile der Ressource, die ich ändern möchte.“ Das sind grundlegend verschiedene Textgattungen, etwa wie „Rezept“, „Foto des Gerichts“ und „Liste der Zutatenersetzungen“. Zu versuchen, ein DTO für alle Fälle zu verwenden, ist, als würde man Lebenslauf, Vertrag und Geburtstagskarte mit derselben Word-Vorlage drucken.

Es gibt noch einen wichtigen Punkt, der Anfänger besonders ärgert: Das Patch-DTO ist kein „abgespecktes vollständiges Modell“. Es ist bewusst eng gefasst. Es sollten nur die Felder enthalten sein, die wir wirklich „punktuell“ ändern möchten und von denen wir wissen, wie wir sie ohne Mehrdeutigkeit ändern können. Alles andere ist nicht enthalten, nicht weil „wir geizig sind“, sondern weil wir den Kontrakt so entwerfen, dass er für den Kunden vorhersagbar und für den Server sicher ist.

2. Felder für das Patch-DTO

Bevor man record TaskPatchRequest(...) schreibt, ist es sinnvoll, innezuhalten und das zu tun, was Programmierer normalerweise nicht mögen: eine Minute nachdenken. PATCH handelt davon, „ich ändere dies“, also müssen wir die Frage beantworten: Was genau darf der Kunde teilweise ändern? Hier bietet es sich an, alle Felder von Task in zwei große Gruppen zu unterteilen: das, was der Kunde steuert, und das, was der Server steuert.

Serververwaltete Felder sind keine „geheimen Felder für die Elite“. Es sind einfach Felder, die vom System gesetzt werden: Identifikator, Zeitpunkte, manchmal berechnete Werte, manchmal Infrastrukturschlüssel. Sie können in der Response sichtbar sein (z. B. id ist fast immer sichtbar), aber sie sollten nicht als Eingabe für Änderungen akzeptiert werden. Wenn Sie dem Kunden erlauben, createdAt zu senden, erhalten Sie irgendwann eine Aufgabe, die „morgen erstellt“ wurde (das Universum wird nicht zusammenbrechen, aber die Berichte definitiv).

Schauen wir uns für unsere Task (aus der Task Tracker API) die Felder an und markieren wir ehrlich: Welche Felder sind patchbar, welche nicht.

Feld des internen Modells Task Woher kommt die „Wahrheit“ Sollte in TaskPatchRequest sein? Warum
id Server (UUID) nein Die ID wird vom Server vergeben und adressiert die Ressource über den Pfad
createdAt Server nein serververwaltet, wird vom Kunden nicht geändert
updatedAt Server nein serververwaltet, wird nach Änderungen berechnet
status Domänenlogik normalerweise nicht (im Rahmen des Tages) Statusübergänge erfordern oft separate Logik/Regeln; besser nicht mit Patch-Feldern vermischen
title Kunde ja Teil des änderbaren Zustands (aber mit eigenen Regeln)
description Kunde ja optionales Feld, geradezu der „Liebling“ von Patch-Szenarien
assigneeName Kunde ja kann zugewiesen/gelöscht werden
dueDate Kunde ja praktisches Beispiel eines Feldes, das oft separat geändert wird
priority Kunde ja enum, eignet sich gut für Patch
tags Kunde ja aber mit expliziter Semantik (üblich: „gesamte Liste ersetzen“)

Beachten Sie: Wir diskutieren nicht, „ob man den Status irgendwann ändern darf“. Das darf man. Nur bauen wir im Rahmen dieses Tages einen verständlichen Patch-ähnlichen Kontrakt auf, und status ist ein zu „reiches“ Feld, das oft mit Geschäftsübergängen und Konflikten verbunden ist. Wenn wir status ohne vorher vereinbarte Regeln in das Patch-DTO stecken, erhalten wir morgen „PATCH und Statusänderung wild durcheinander“ und übermorgen „Warum können wir Übergänge nicht einschränken?“. Statt eines API-Kontrakts ergibt sich eine Lotterie.

Daraus ergibt sich eine praktische Regel: In das Patch-DTO kommen nur „normale“ änderbare Felder. Wenn ein Feld einen Geschäftsprozess nach sich zieht (z. B. Statusübergang), sollte man es besser nicht in den „allgemeinen PATCH-Sack“ stecken, solange man nicht bereit ist, diesen Geschäftsprozess separat und bewusst zu entwerfen.

3. TaskPatchRequest im Code

Jetzt sind wir bereit, vom „was wir wollen“ zum „wie sieht es in Java aus“ überzugehen. Dabei ist es wichtig, keine Komödie der Art zu veranstalten: „Ach, machen wir doch alle Felder zu Object, damit Jackson alles genau einliest.“ Jackson wird es natürlich einlesen. Aber dann werden Sie es im Service lesen und leise in Ihr debug-Log weinen.

Das Patch-DTO bleibt in unserem Kurs typisiert. Es ist kein „dynamischer JSON-Beutel“, sondern ein sauberes Eingabemodell. Daher wählen wir die Felder entsprechend dem, was wir bereits im JSON-Baseline gezeigt haben: Strings bleiben Strings, Daten bleiben LocalDate, Aufzählungen bleiben enum, Sammlungen bleiben List<String>.

Hier ist das kanonische Beispiel:

package com.example.tasktracker.api.dto.request;

import java.time.LocalDate;
import java.util.List;

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

/**
 * DTO für PATCH: alle Felder sind auf Java-Ebene optional.
 * Das DTO selbst unterscheidet nicht zwischen `absent` und `explicit null` – diese Grenze wird durch den Kontrakt
 * und die Art des Eingangslesens festgelegt.
 */
public record TaskPatchRequest(
        // Neuer Titel der Aufgabe (falls der Kunde genau diesen ändern möchte)
        String title,
        // Neue Beschreibung (häufiges Szenario: Text hinzufügen/korrigieren)
        String description,
        // Bearbeiter: kann gesetzt oder gelöscht werden (abhängig von den Regeln zur Null-Behandlung)
        String assigneeName,
        // Fälligkeitsdatum: lokales Datum ohne Zeit
        LocalDate dueDate,
        // Priorität: enum, um „magische Strings“ am Eingang zu vermeiden
        TaskPriority priority,
        // Tags: üblich als „gesamte Liste ersetzen“, nicht „ein Tag hinzufügen“
        List<String> tags
) {
}

Warum sieht es genau so aus (und warum ist das in Ordnung):

Wir machen die Felder bewusst nullable. Im Patch-Modell ist „Optionalität“ nicht „uns ist es egal“, sondern dass die Java-Repräsentation das Fehlen eines Werts zulassen muss. Aber das DTO selbst speichert noch nicht die Tatsache, ob ein Feld im JSON vorhanden war, daher können absent und explicit null nicht in einem Satz wie „null = nicht ändern“ zusammengefasst werden. Für description kann null ein Befehl zum Löschen sein, für title dagegen ein Eingabefehler. Daher liest man das Patch-DTO besser als Liste potenziell änderbarer Felder und nicht als fertige Merge-Strategie.

Beachten Sie auch tags. Ein häufiger Anfängerfehler ist es, Tags in ein Set<String> zu packen und sich über die „Eindeutigkeit“ zu freuen. Aber JSON ist kein Set, sondern eine Liste von Werten, und für ein DTO ist es einfacher, List<String> zu verwenden; Eindeutigkeit und Größen-/Formatbeschränkungen sind ein separates Thema (später im Validation-Modul). Jetzt ist wichtig, dass der Kontrakt für den Kunden verständlich ist: Er sendet ein Array von Strings – wir lesen ein Array von Strings.

4. taskId: Pfad getrennt vom Body

Man möchte oft dem TaskPatchRequest ein Feld id hinzufügen, damit es „bequem“ ist. Das ist ein Klassiker: „damit alles in einem Objekt steckt“. Aber genau davon versucht der REST-Kontrakt uns zu befreien. In unserer API ist die Adresse der Ressource der Pfad, und die Beschreibung der Änderung ist der Body. Diese Rollen zu vermischen, verschlechtert die Lesbarkeit des Kontrakts und fügt unnötige Prüfungen hinzu.

Wenn taskId sowohl im Pfad als auch im Body ankommt, stellt sich die Frage: „Wenn sie unterschiedlich sind, wem glauben wir?“ Und plötzlich schreiben Sie Vergleichslogik, Fehler wie „id mismatch“ – alles nur, weil Sie einmal dachten: „Machen wir es bequem.“ Das ist, als würde man zwei Lenkräder in ein Auto einbauen: Ja, theoretisch kann man damit fahren, aber Sie haben selbst das Synchronisationsproblem geschaffen.

Das normale Modell sieht so aus:

flowchart TD
    %% Adresse der Ressource (Path) und Änderungsdaten (Body) nicht in einem DTO vermischen
    A["PATCH /api/v1/tasks/{taskId}"] --> B["taskId (path) = Ressourcenadresse"]
    A --> C["body = TaskPatchRequest = Änderungen"]
    C --> D["title/description/... (Teil kann fehlen)"]

Genau so wollen wir die Signatur der Controller-Methode sehen: ein Argument für die ID, ein Argument für das Patch-DTO.

Beispiel eines Controllers im Projektstil:

package com.example.tasktracker.api.controller;

import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import com.example.tasktracker.api.dto.request.TaskPatchRequest;
import com.example.tasktracker.api.dto.response.TaskDetailsResponse;

@RestController
public class TaskController {

    @PatchMapping("/api/v1/tasks/{taskId}")
    public TaskDetailsResponse patchTask(
            // Ressourcen-ID kommt aus dem Pfad: sie ist verantwortlich für „was wir ändern“
            @PathVariable String taskId,
            // DTO im Body ist nur verantwortlich für „wie wir ändern“
            @RequestBody TaskPatchRequest request
    ) {
        // Hier wichtig ist die Grenze: Path definiert die Ressource, Body beschreibt die Änderungen.
        // Die Merge-Regeln und Eingangsprüfungen leben weiter unten in der Kette.
        throw new UnsupportedOperationException("Not implemented");
    }
}

Ein solches Grundgerüst legt die äußere Form des Endpunkts fest: taskId im Pfad, patchbare Felder im Body. Man kann das Einlesen des Anfragekörpers auf unterschiedliche Weise implementieren, um die Tatsache des Vorhandenseins eines Feldes nicht zu verlieren, aber die Grenze Path/Body ändert sich dadurch nicht.

5. Antwort nach PATCH

Es gibt die Versuchung, es so zu machen: „Der Kunde hat TaskPatchRequest gesendet, dann geben wir ihm einfach TaskPatchRequest zurück – das spart doch!“ Sparen tut es sicher. Es spart an Sinn. Nach einem PATCH möchte der Kunde normalerweise verstehen, wie die Ressource jetzt aussieht. Aber ein Patch-DTO ist nicht „Ressource geworden“, es ist „was wir zu ändern versucht haben“.

Daher sieht der äußere Kontrakt eines gesunden API so aus: Eingang = Patch-DTO, Ausgang = vollwertiges Response-DTO (normalerweise das Detail-Modell), in dem der Server den aktuellen Zustand der Ressource nach der Änderung zeigt. Das ist besonders wichtig, weil der Server:

Tags neu anordnen, eine Normalisierung anwenden, updatedAt aktualisieren, abgeleitete Felder berechnen oder einfach eine aktuelle Momentaufnahme zurückgeben kann. Für den Kunden ist es nützlicher, die „Wahrheit des Servers“ zu sehen, als noch einmal das zu sehen, was er ohnehin gesendet hat.

Deshalb halten wir an der architektonischen Disziplin fest: Im PATCH-Endpunkt geben wir TaskDetailsResponse (oder ein ähnliches Detail-Response-Modell) zurück. Und das ist nicht „überflüssig“. Es macht die API vorhersagbar: Nach jeder Änderung kann der Kunde bestätigt bekommen, dass der Server ihn richtig verstanden hat.

Antibeispiel: Response DTO als Eingang

Nun veranschaulichen wir ein schlechtes Beispiel, denn manchmal bleibt das gute Beispiel ohne das schlechte nicht im Gedächtnis. Angenommen, wir haben TaskDetailsResponse (ein Response-DTO) und entscheiden: „Ach, nehmen wir es doch als Request, es existiert ja bereits.“

Was kann schiefgehen? So ziemlich alles.

package com.example.tasktracker.api.controller;

import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;

import com.example.tasktracker.api.dto.response.TaskDetailsResponse;

public class BadTaskController {

    @PatchMapping("/api/v1/tasks/{taskId}")
    public TaskDetailsResponse patchTask(
            // Auch wenn id aus dem Pfad kommt, bleibt das Problem: Der Body wird zu „breit“
            @PathVariable String taskId,
            // SCHLECHT: Response DTO als Eingang => Client kann serververwaltete Felder und unnötige Daten senden
            @RequestBody TaskDetailsResponse request
    ) {
        // Stub: Beispiel dient als Illustration des Antipatterns, nicht als funktionierender Code
        throw new UnsupportedOperationException();
    }
}

Äußerlich sieht das „logisch“ aus. Aber kontraktuell bedeutet es: Der Kunde kann nun alles senden, was Sie im Detail-Response gezeigt haben. Und der Detail-Response enthält mit ziemlicher Sicherheit serververwaltete Felder (id, createdAt, updatedAt) und möglicherweise Felder, die Sie auf diese Weise gar nicht ändern möchten. Sie haben das API breit und schwer kontrollierbar gemacht.

Außerdem haben Sie zwei Rollen vermischt: Das Response-DTO ist normalerweise auf Lesen optimiert, das Request-DTO auf Eingabe. Request-DTOs können andere Einschränkungen, andere Namen, eine andere Haltung gegenüber null und dem Fehlen eines Feldes haben. Wenn Sie das Response-DTO als Eingang wiederverwenden, verlieren Sie die Möglichkeit, diese Unterschiede ordentlich zu verwalten.

Und ja, als Sahnehäubchen: Sie erhöhen das Risiko, dass irgendwann jemand dem TaskDetailsResponse ein neues Feld hinzufügt (z. B. archived oder internalFlags), und plötzlich senden Kunden dieses Feld. Sie haben es nicht geplant. Solche Fehler sind besonders lustig, weil sie aussehen wie „es hat alles funktioniert, und jetzt ist es komisch“.

6. Typische Fehler beim Entwurf von TaskPatchRequest

Fehler Nr. 1: „Ich mache das Patch-DTO zur Kopie des Detail-Response, nur ohne ein paar Felder.“
Dieser Ansatz führt fast immer zu einer Erweiterung des Kontrakts ohne Ihre Zustimmung. Detail-Response und Patch-Eingabe haben unterschiedliche Ziele. Der Detail-Response soll den Zustand der Ressource zeigen, das Patch-DTO soll einschränken, was geändert werden kann. Wenn Sie diese Rollen verwechseln, schaffen Sie sich selbst zukünftige Breaking Changes und Probleme mit Over-Posting.

Fehler Nr. 2: Hinzufügen serververwalteter Felder „der Bequemlichkeit halber“.
Meistens sind das id, createdAt, updatedAt. In einem Patch-DTO haben sie nichts zu suchen. Sonst müssen Sie sich überlegen, was Sie mit einer „Änderung von createdAt“ tun sollen – das ist normalerweise nicht das, womit man sich bei klarem Verstand beschäftigen möchte. Das richtige Modell: taskId kommt über den Pfad, Zeitstempel aktualisiert der Server.

Fehler Nr. 3: Der Versuch, ein „universelles DTO für alle Operationen“ zu erstellen.
Ein DTO für Create, Put, Patch, Response – klingt nach Einsparung, ist aber faktisch das Abschalten der Kontraktdisziplin. Das API wird unscharf: Es ist unklar, welche Felder obligatorisch sind, welche gesendet werden dürfen, welche ignoriert werden, welche verboten sind. In unserem Kurs ist der Ansatz umgekehrt: Eine Operation – ein klares Ein-/Ausgabemodell.

Fehler Nr. 4: Aufnahme „komplexer“ Felder ohne Vereinbarung über deren Bedeutung.
Zum Beispiel der Aufgabenstatus. Wenn Sie status ohne Regeln in das Patch-DTO aufnehmen, haben Sie sehr schnell „PATCH ändert den Status wild durcheinander“ und danach den Versuch, Einschränkungen nachträglich anzubringen. Das ist schmerzhaft für den Code und für die Kunden. Halten Sie das Patch-DTO lieber fokussiert auf Felder, die sich wirklich bequem teilweise ändern lassen.

Fehler Nr. 5: Vermischen der Ressourcenadresse und der Änderungsbeschreibung.
Wenn taskId (oder id) gleichzeitig im Pfad und im Body ankommt, müssen Sie einen Wertekonflikt lösen. Das ist ein künstliches Problem. Die Ressourcenadresse sollte im Pfad sein, die Änderungen im Body. So liest sich der Kontrakt leichter – sowohl für Menschen als auch für den Client-Code.

1
Aufgabe
Spring REST & MVC, Level 13, Lektion 1
Gesperrt
Separat `TaskPatchRequest` für den PATCH-Endpunkt
Separat `TaskPatchRequest` für den PATCH-Endpunkt
1
Aufgabe
Spring REST & MVC, Level 13, Lektion 1
Gesperrt
Patch DTO für Autorenprofil ohne server-managed Felder
Patch DTO für Autorenprofil ohne server-managed Felder
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION