Zum Inhalt springen

Schreibsemantik – PUT, PATCH, DELETE

Kurzantwort: PUT ist eine Aussage über die ganze Ressource, PATCH über die genannten Felder. Was in einem PUT fehlt, wird geleert bzw. entkoppelt. Was in einem PATCH fehlt, bleibt unangetastet – ein ausdrückliches null leert dagegen. Einen save-Endpunkt gibt es nicht.


Feldartfehltausdrücklich nullWert
Skalarauf null gesetztauf null gesetztgesetzt
Referenzentkoppelt; bei RecursiveType.DELETE gelöschtwie „fehlt”gesetzt bzw. rekursiv geschrieben
Listealle Mitglieder entferntwie „fehlt”Zielzustand: nicht enthaltene Mitglieder entfernt

PUT unterscheidet „fehlt” und null nicht. Der Payload ist ein typisiertes Objekt; nach der Deserialisierung sind beide Fälle dasselbe null.

Feldartfehltausdrücklich nullWert
Skalarunverändertgeleertgesetzt
Referenzunverändertentkoppelt; bei RecursiveType.DELETE gelöschtsiehe „Verschachtelte Objekte”
Listeunverändertalle Mitglieder entferntZielzustand; [] leert vollständig

PATCH transportiert eine HashMap und kennt den Tri-State fehlend / null / Wert deshalb wirklich.

// FALSCH – leert alles, was nicht gesetzt wurde
EmployeeUpdatePayload p = new EmployeeUpdatePayload();
p.setId(id);
p.setFirstname("Test"); // lastname, department … bleiben null
api.patch(objectMapper.writeValueAsString(new WritePayload<>(p)));

Jackson serialisiert das Objekt vollständig, also mit "lastname": null für jedes nicht gesetzte Feld – und das leert die Felder.

// RICHTIG – nur nennen, was sich ändern soll
{ "data": { "id": "…", "firstname": "Test" }, "response": ["+"] }

Wer ein Formularmodell oder ein generiertes Payload-Objekt an PATCH schickt, löscht alles, was er nicht ausdrücklich gesetzt hat.

Was mit einem Eintrag in einer Referenz oder Liste passiert, hängt an der id und an den Recursive-Flags der Beziehung:

EintragFlag der BeziehungVerhalten
mit idUPDATE gesetztwird rekursiv mitgeschrieben – bei PUT als Replace, bei PATCH als Patch
mit idUPDATE nicht gesetztnur referenziert, Inhalt unangetastet
ohne idCREATE gesetztwird angelegt
ohne idCREATE nicht gesetztStrict Mode: 400 recursive-create-not-allowed|<feld>; sonst wird der Eintrag still verworfen

Diese Regeln gelten auf jeder Ebene. Der Operationskontext wechselt dabei pro Ebene: Ein PATCH auf ein Elternobjekt kann den Create eines Kindes enthalten – für das Kind gelten dann Create-Regeln, für den Elternteil Update-Regeln, im selben Request.

Back-Reference: Beim rekursiven Schreiben bleibt das Feld, das auf das auslösende Elternobjekt zurückzeigt, unangetastet. Die Zugehörigkeit verwaltet ausschließlich der Parent – sonst würde ein Teilobjekt die gerade hergestellte Verknüpfung wieder auflösen.

flowchart TB
    A["Referenz soll geleert werden"] --> B{"RecursiveType.DELETE<br/>auf der Beziehung?"}
    B -->|nein| C["Objekt überlebt<br/>Rück-Referenz wird bereinigt<br/>updateObject"]
    B -->|ja| D["abhängiges Kind<br/>recursiveDelete + deleteObject<br/>späteres Lesen: 404"]

Das gilt für PUT und PATCH gleichermaßen – beide nutzen denselben Codepfad. Löschungen durchlaufen dabei die DELETE-Hooks.

DELETE /{model}/delete/{id} löscht das Objekt und – über RecursiveType.DELETE – seine abhängigen Kinder, rekursiv. Für jedes betroffene Objekt feuern die DELETE-Hooks über dessen eigenen System-Layer. Ist das Objekt ein Datei-Modell, wird auch der Inhalt im Storage gelöscht, inklusive aller aufbewahrten Versionen.

Nicht-abhängige Beziehungen werden nur entkoppelt: die Rück-Referenz wird geleert und das Gegenüber gespeichert.

Dieselben Regeln, nur ohne ID: /…/update statt /…/update/{id}, der Endpunkt löst die ID selbst auf und setzt sie vor dem Patch in die Map ein.

Die ältere Konzeptdokumentation beschreibt Save-Endpunkte (Create-oder-Update, verzweigt über data.id). Der Generator erzeugt sie nicht. Vorhanden sind create, read, update (PUT), update (PATCH), delete, query sowie optional file, history und rollback.

Wer Create-oder-Update braucht, entscheidet clientseitig anhand der id.

Das ist auch der Grund, warum es kein WritePayload<SavePayload> gibt: Ein WritePayload<CreatePayload> lässt sich nicht auf WritePayload<UpdatePayload> casten, weil es unterschiedliche Klassen sind.

Jedes geschriebene Feld wird gegen die Regeln der Operation geprüft, nicht des Endpunkts: CREATE gegen die Create-Regeln, UPDATE und PATCH gegen die Update-Regeln. Ein PATCH validiert nur, was er auch schreibt.

Alle Verstöße eines Requests werden gesammelt und in einer 422-Antwort mit Feldpfad gemeldet. Siehe Validierung.

Im generierten Code gibt es kein @Version-Feld. Wer für ein PUT den Ist-Zustand liest, ändert und zurückschreibt, kann fremde Änderungen dazwischen überschreiben. Wo das kritisch ist, hilft PATCH (schreibt nur die genannten Felder) oder eine fachliche Prüfung im Hook.