1. save() – eine vertraute und oft missverstandene Methode
Wenn man einen Anfänger bittet zu zeigen, „wo die Daten gespeichert werden“, wird er fast reflexartig auf repository.save(...) zeigen. Das ist normal: Der Name der Methode ist maximal „menschlich“, fast wie der „Speichern“-Button in Excel. Das Problem ist, dass sich unter diesem Wort im ORM mehrere unterschiedliche Szenarien verbergen, jedes mit seinen eigenen SQL-Kosten und Nebenwirkungen.
In Spring Data JPA gibt es nur eine save-Methode, aber eine Entität kann sich in verschiedenen Lebenslagen befinden. Ein Objekt kann neu (transient) sein, es kann bereits innerhalb der aktuellen Transaktion gemanaged werden, oder es kann als detached ankommen (eine typische Geschichte: DTO → Mapper → „Entität“, aber ohne Verbindung zum Persistenzkontext). Und jedes Mal bedeutet derselbe save etwas leicht Unterschiedliches.
Die wichtigste Übersetzung in die Sprache von Hibernate lautet: save(...) speichert die Entität nicht in einem luftleeren Raum, sondern hilft lediglich, die Entität in den richtigen Persistenzablauf zu bringen. Manchmal bedeutet das persist, manchmal merge. Manchmal – überhaupt nichts Wichtiges, außer einer zusätzlichen Codezeile (ja, eine „zusätzliche Codezeile“ kann auch ein Bug sein, nur gibt sie sich noch harmlos).
2. Was macht save(...): persist oder merge
Wenn wir sagen „Spring Data hat CRUD für uns erledigt“, vergessen wir leicht, dass im Inneren immer noch der gewöhnliche EntityManager lebt. Und save in einem typischen Spring Data JPA-Projekt ist in erster Linie eine Entscheidungsfrage: welche JPA-Operation ausgeführt werden soll – persist oder merge. Das ist also keine Magie, sondern – nun ja – Magie mit geöffneter Motorhaube.
Stark vereinfacht (aber methodisch ehrlich) sieht die Logik so aus:
import jakarta.persistence.EntityManager;
public class PseudoSaveLogic {
private EntityManager em;
public <T> T save(T entity, boolean isNew) {
// Wenn die Entität "neu" ist, registrieren wir genau dieses Objekt im Persistenzkontext
if (isNew) {
em.persist(entity); // das Objekt wird gemanaged
return entity; // wir geben dieselbe Instanz zurück
}
// Wenn die Entität "nicht neu" ist, handelt es sich meistens um merge:
// es wird eine gemanagede Kopie zurückgegeben (nicht unbedingt dasselbe Objekt)
return em.merge(entity);
}
}
Im echten Spring Data JPA wird diese Wahl nicht über ein isNew-Flag getroffen, sondern über einen Mechanismus zur Bestimmung der Neuheit (newness), aber das Prinzip ist genau dasselbe.
Um dies visuell zu verankern, kann man sich ein kleines Diagramm merken:
flowchart TD
A["productRepository.save(entity)"] --> B{"Entity neu?"}
B -- "Ja" --> C["EntityManager.persist(entity)"]
C --> D["entity wird gemanaged
INSERT normalerweise bei flush/commit"]
B -- "Nein" --> E["EntityManager.merge(entity)"]
E --> F["gemanagte Kopie wird zurückgegeben
SELECT möglich, UPDATE bei flush"]
Eine wichtige, oft übersehene Detail: Im merge-Zweig ist der Rückgabewert kritisch wichtig. Es ist nicht „dasselbe Objekt, nur gespeichert“. Es ist eine gemanagede Instanz im aktuellen Persistenzkontext, und nur mit dieser sollte man innerhalb der Transaktion sicher weiterarbeiten.
3. Newness: Wie Spring Data eine neue Entität erkennt
Das Wort „newness“ klingt, als würden wir über die Philosophie der „Neuheit“ diskutieren, aber in der Praxis ist es eine bodenständige Sache: Spring Data muss herausfinden, welchen Zweig es wählen soll – persist oder merge. Und es kann Ihre Gedanken nicht lesen. Es betrachtet die Entität und versucht, eine technisch vernünftige Annahme zu treffen.
In den meisten Projekten (einschließlich unserem Commerce Persistence Lab) wird die Neuheit recht erwartbar bestimmt: Wenn die Entität keine ID hat, ist sie neu. Daher ist bei einer Entity mit @GeneratedValue das typische Merkmal eines neuen Objekts – id == null. Aber Spring Data hat noch ein weiteres wichtiges Signal: die Version (@Version). Wenn die Entität versioniert ist, ist es ziemlich logisch, eine Entität als neu zu betrachten, wenn version == null (wenn die Version ein objekttyp wie Long ist, kein primitiver long).
Das Ergebnis ist ungefähr diese „Intuitionstabelle“:
| Entität | Was wird normalerweise geprüft | Warum funktioniert es |
|---|---|---|
| Entity mit @GeneratedValue ohne Version | id == null → new | ID erscheint nach persist/flush, davor ist das Objekt neu |
| Entity mit @Version (Long version) | version == null → new | Eine neue Entität hat noch nicht an einer versionierten Aktualisierung teilgenommen |
| Entity mit zugewiesener ID (id selbst gesetzt) | funktioniert oft nicht „standardmäßig“ | id != null, aber das Objekt ist immer noch transient; merge kann sich unerwartet verhalten |
Die dritte Zeile ist eine Quelle von „Abenteuern“: Wenn Sie die ID manuell vergeben (zugewiesene ID), kann man allein anhand von id != null nicht ehrlich sagen, dass das Objekt „bereits in der Datenbank“ ist. Es könnte sich um eine neue Entität handeln, der Sie im Voraus einen Identifikator zugewiesen haben. Dann könnte Spring Data fälschlicherweise den merge-Zweig nehmen (was einen überflüssigen SELECT und manchmal unerwartete Effekte bedeuten kann).
In unserem Studienprojekt halten wir uns bewusst an die ID-Generierung durch Hibernate/Datenbank (nicht zugewiesen), daher wird die grundlegende Neuheits-Intuition vorhersagbar funktionieren. Aber selbst in diesem Fall ist es wichtig, sich daran zu erinnern: save ist nicht „INSERT/UPDATE“, sondern „persist/merge“ + der restliche Ablauf wird durch den Flush-Zyklus bestimmt.
Wenn ein Projekt bewusst mit zugewiesenen IDs arbeitet, kann diese Heuristik nicht mehr standardmäßig verwendet werden: Die Neuheit muss explizit bestimmt werden, z.B. über Persistable. In unserem Fall bleibt das übliche Szenario mit generierter ID und vorhersagbarem persist-Zweig.
4. save(...) und neues Objekt
Ein neues Objekt in Java ist noch keine Zeile in der Tabelle. Der häufigste Denkfehler ist die Erwartung, dass save sofort einen INSERT sendet. Tatsächlich bedeutet save für ein neues Objekt normalerweise: „Mache die Entität gemanaged“. Das SQL kann sofort oder später gesendet werden – das hängt von der ID-Strategie, dem Flush-Modus und den Flush-Triggern ab (die wir bereits besprochen haben).
Schauen wir uns die „Produkterstellung“ in unserem Commerce Persistence Lab an. Der Einfachheit halber nehmen wir an, dass Product sku und name hat und id generiert wird.
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ProductWriteService {
private final ProductRepository productRepository;
public ProductWriteService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Transactional
public Product create(String sku, String name) {
// Das ist erstmal nur ein Java-Objekt, es hat noch keine direkte Beziehung zur Datenbank
Product product = new Product(sku, name);
// Wichtig: save() führt hier die Entität in erster Linie in den Persistenzablauf ein (persist)
// Ein INSERT erfolgt meistens später – beim flush/commit
return productRepository.save(product); // SQL kann später (beim flush) gesendet werden
}
}
Was man mit den Augen (und durch SQL-Trace) sehen sollte: Nach save(product) ist das Objekt in der Regel bereits gemanaged, d.h. Hibernate beginnt, es zu überwachen. Aber ein INSERT erscheint meistens erst bei flush, also entweder beim Commit der Transaktion oder früher – wenn ein Flush-Trigger auftritt (z.B. wenn Sie eine Abfrage ausführen, die diese Änderungen sehen soll).
Und hier stellt sich eine interessante Frage: „Warum überhaupt Product aus create() zurückgeben?“ Die praktische Antwort: Im gemanagten Szenario ist das praktisch für den aufrufenden Code, um sofort id zu erhalten (wenn bereits zugewiesen) und um die Referenz auf das Objekt nicht zu verlieren. Aber noch einmal: Das Vorhandensein von id und der tatsächliche SQL-Insert sind nicht dasselbe zeitliche Ereignis.
5. save(...) für gemanagede Entitäten: warum es meistens überflüssig ist
Nach einigen Wochen Arbeit mit Spring Data entwickelt sich bei vielen eine Gewohnheit: „Feld geändert – save aufrufen“. Das sieht nach Disziplin aus, ist aber inhaltlich oft nur eine Verdopplung eines bereits funktionierenden Mechanismus. Wenn Sie innerhalb einer Transaktion eine Entität geladen haben, wird sie gemanaged. Danach ändern Sie Felder, Hibernate führt Dirty Checking durch und sendet beim flush ein UPDATE. Das Repository ist hier nicht mehr der Hauptakteur.
Vergleichen wir zwei Varianten der Namensänderung eines Produkts. Die erste – „wie gewohnt“:
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void renameWithRitualSave(Long id, String newName) {
// findById innerhalb der Transaktion liefert eine gemanagede Entität
Product product = productRepository.findById(id).orElseThrow();
// Zustand des gemanagten Objekts ändern
product.renameTo(newName);
// Meistens überflüssig: das UPDATE erfolgt trotzdem beim flush/commit (dirty checking)
productRepository.save(product); // meistens redundant
}
Die zweite – „wie das ORM tatsächlich funktioniert“:
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void renameManaged(Long id, String newName) {
// Die Entität wird sofort nach dem Laden gemanaged
Product product = productRepository.findById(id).orElseThrow();
// Es reicht, die Felder zu ändern:
// Hibernate erkennt die Änderungen selbst und führt das UPDATE beim flush/commit durch
product.renameTo(newName); // dirty checking führt UPDATE beim flush/commit durch
}
Aus Sicht des resultierenden SQLs gibt es in vielen Fällen keinen Unterschied: Das UPDATE wird trotzdem ausgeführt. Aber es gibt einen Unterschied auf der mentalen Modellebene. In der zweiten Variante erkennen Sie ehrlich an: „Das Update macht Hibernate, weil das Objekt gemanaged ist“. In der ersten Variante erzeugen Sie die Illusion, dass „das Repository speichert“. Und Illusionen in der Persistenzschicht sind teuer: Sie behindern das Debuggen von Flush, Massennebenwirkungen, veraltetem Zustand und überhaupt allem „Warum hat es dieses SQL gemacht?“
Manchmal lässt man save auf einem gemanagten Objekt als stilistische Entscheidung „überall gleich“ stehen. In einem Studienprojekt sollte man das vermeiden, damit der Code explizit das Laufzeitmodell widerspiegelt.
6. save(...) und detached: merge und Kopie
Das Detached-Szenario ist das tückischste, weil es äußerlich genauso aussieht: Sie haben ein Product-Objekt, Sie ändern es, Sie rufen save auf. Scheint einfach. Aber unter der Haube wird save für ein detached-Objekt fast immer zu merge. Und merge gibt eine gemanagede Instanz zurück, die möglicherweise nicht dasselbe Objekt ist, das Sie übergeben haben.
Wir zeigen ein Beispiel „Update aus der Außenwelt“ (z.B. ein DTO kommt an, wir bauen eine Entity zusammen und versuchen zu speichern):
import org.springframework.transaction.annotation.Transactional;
@Transactional
public Product updateDetached(Product detached) {
// Das detached-Objekt wird NICHT vom Persistenzkontext überwacht
detached.renameTo("New name");
// save(detached) bedeutet hier normalerweise merge()
// Wichtig: Es wird eine gemanagede Kopie zurückgegeben, mit der man weiterarbeiten muss
return productRepository.save(detached); // merge: gibt gemanagede Kopie zurück
}
Wenn Sie innerhalb derselben Transaktion danach mit dem alten detached-Objekt weiterarbeiten, kann es zu sehr merkwürdigen Gefühlen kommen: „Ich habe das Feld geändert, warum steht es nicht in der Datenbank?“ oder „Warum sind in den Logs nicht die richtigen Werte?“. Die richtige Regel klingt langweilig, spart aber Nerven: Nach save(detached) arbeiten Sie mit dem Objekt weiter, das von save zurückgegeben wurde.
Um die Falle möglichst anschaulich zu machen, stellen Sie sich (übertrieben) diesen Code vor:
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void brokenDetachedFlow(Product detached) {
// merge erzeugt/gibt eine gemanagede Instanz im aktuellen Persistenzkontext zurück
Product managed = productRepository.save(detached);
// detached ändern: Diese Änderungen könnten überhaupt nicht in die DB gelangen
detached.renameTo("Oops"); // wir ändern NICHT das richtige Objekt
// managed ändern: Das wird Hibernate sehen und beim flush/commit schreiben
managed.renameTo("Ok"); // wir ändern das gemanagte Objekt
}
Aus Sicht von Hibernate ist nur das managed-Objekt wichtig. Änderungen am detached-Objekt nach dem merge können überhaupt nicht in den Persistenzkontext gelangen, weil detached nur ein gewöhnliches Objekt im Speicher ist, „nicht überwacht“.
Und ja, das ist genau der Fall, in dem find + mutieren oft einfacher und sicherer ist: Sie holen sich zuerst eine gemanagede Entität aus der Datenbank und übertragen dann gezielt die Änderungen auf sie. Das haben wir früher besprochen, und save ist hier nicht die „beste“ Variante – es ist nur eine von mehreren, und nicht immer die vorhersagbarste.
7. saveAndFlush(...): früher Flush und SQL sofort
saveAndFlush klingt nach „save, aber mit Garantie“. Im menschlichen Sprachgebrauch möchte man es lesen: „Speichere zuverlässig“. Aus technischer Sicht bedeutet es etwas anderes: speichere und erzwinge den flush, also „sende SQL jetzt sofort an die Datenbank, auch wenn die Transaktion noch nicht zu Ende ist“. Und dieses „jetzt sofort“ ist manchmal kritisch, manchmal aber auch nur zusätzliche Probleme.
Wir zeigen ein minimales Beispiel:
import org.springframework.transaction.annotation.Transactional;
@Transactional
public Product createAndFlush(String sku, String name) {
// Neues Objekt erstellen
Product product = new Product(sku, name);
// saveAndFlush() = save() + erzwungener Flush genau hier
// Das ist KEIN Commit: Die Transaktion kann immer noch zurückgerollt werden
return productRepository.saveAndFlush(product); // SQL wird sofort beim flush gesendet
}
Wichtig: Flush ist nicht gleich Commit. Nach saveAndFlush(...) kann die Transaktion immer noch zurückgerollt werden, und die Änderungen werden nicht permanent. Aber die Datenbank sieht die SQL-Operation innerhalb der Transaktion: Constraints (z.B. Eindeutigkeit von sku) werden geprüft, Sperren können gesetzt werden, Trigger können ausgelöst werden (falls vorhanden), und vor allem: Ein Fehler kann früher auftreten als erwartet.
Es ist hilfreich, dies als Zeitachse zu betrachten:
sequenceDiagram
participant S as "Service (@Transactional)"
participant EM as "Hibernate (Persistenzkontext)"
participant DB as PostgreSQL
S->>EM: save(entity)
Note over EM: entity wird gemanaged, SQL kann verzögert sein
S->>EM: saveAndFlush(entity)
EM->>DB: INSERT/UPDATE (flush)
Note over DB: constraints überprüft, Fehler kann jetzt auftreten
S->>DB: COMMIT (Ende der Transaktion)
Wenn Sie save ohne flush aufrufen, treten Eindeutigkeitsfehler oft erst kurz vor dem Commit auf (oder beim Auto-Flush vor einer Abfrage). Wenn Sie saveAndFlush aufrufen, erscheint der Fehler sofort nach diesem Aufruf. Das ist manchmal praktisch (fail fast), aber es ist nicht „zuverlässiger“, sondern „früher“.
8. Wo ist saveAndFlush(...) angemessen?
Es besteht die Versuchung, saveAndFlush als universelles Mittel gegen Ängste einzusetzen: „falls es nicht gespeichert wird“. Aber in der Hibernate-Welt ist das, als würde man alle drei Sekunden prüfen, ob die Tür geschlossen ist. Ja, Sie fühlen sich unter Kontrolle, aber das System leidet darunter: mehr flushes, mehr SQL vorzeitig, längere Haltezeiten von Sperren, mehr unerwartete Fehler „mitten in der Methode“.
Ein gerechtfertigter Fall hängt meistens damit zusammen, dass Sie das Ergebnis der Interaktion mit der Datenbank früher erfahren müssen. Zum Beispiel erstellen Sie ein Product mit einer eindeutigen sku und möchten sofort an der Unique-Constraint scheitern, bevor Sie innerhalb derselben Transaktion noch viel andere Logik ausführen. Das ändert nichts daran, dass die Datenbank die Eindeutigkeit gewährleisten muss, aber es hilft, den Fehler schneller zu bekommen und keine Zeit mit sinnloser Arbeit zu verschwenden.
Ein weiteres normales Szenario ist, wenn Sie innerhalb einer Transaktion einen Schritt haben, der unbedingt erfordert, dass SQL bereits gesendet wurde (z.B. danach folgt eine Abfrage, die diese Änderungen sehen muss, und Sie möchten sich nicht auf das Auto-Flush „nach den Regeln“ verlassen, sondern den Flush-Moment explizit und im Code lesbar machen). Das ist selten, aber manchmal ist solcher Code tatsächlich verständlicher: „Hier ist die Grenze, nach der die Datenbank garantiert synchronisiert ist“.
In den übrigen Fällen maskiert saveAndFlush oft ein architektonisches Problem. Zum Beispiel, wenn Transaktionen zu lang sind und der Entwickler versucht, die Mitte der Transaktion durch Flushes zu „entlasten“; oder wenn die Service-Schicht die Grenzen nicht kontrolliert und jemand versucht, manuelle Vorhersagbarkeit zu erreichen. In solchen Situationen wird saveAndFlush eher Chaos hinzufügen als beseitigen.
Zur Verdeutlichung kann man save und saveAndFlush schnell in ihrer Bedeutung vergleichen:
| Methode | Was garantiert sie | Was garantiert sie NICHT | Wichtigster Preis |
|---|---|---|---|
| save() | Entität ist in den Persistenzablauf eingetreten (persist/merge) | dass SQL bereits an die DB gesendet wurde | Kosten werden durch den Flush-Zyklus bestimmt |
| saveAndFlush() | SQL ist in diesem Moment mit der DB synchronisiert | Commit (die Transaktion kann immer noch zurückgerollt werden) | frühe Constraints/Sperren + zusätzliche Flushes |
9. Typische Fehler im Umgang mit save und saveAndFlush
Fehler rund um save entstehen fast nie aus Unkenntnis der Annotationen, sondern aus einem falschen mentalen Modell. Spring Data macht den Code kürzer, und dadurch scheint es, „es macht alles von selbst“. Aber Hibernate befolgt immer noch ehrlich die Regeln des Persistenzkontexts, und wenn man die Regeln nicht versteht, wird man SQL wie einen Zaubertrick bestaunen: „Wie hat es das gemacht?“. Im Folgenden die häufigsten Fallstricke, über die selbst sehr fähige Leute stolpern.
Fehler Nr. 1: save als „sofortigen INSERT/UPDATE“ betrachten.
Wegen des Methodennamens möchte man glauben, dass das SQL genau zum Zeitpunkt des Aufrufs gesendet wird. Aber meistens wird das SQL beim flush gesendet. Infolgedessen baut der Entwickler den Code so auf, als ob der Datensatz bereits in der Datenbank wäre, und wundert sich dann, warum die nächste Abfrage plötzlich einen Flush auslöst und an einem Constraint „an unerwarteter Stelle“ scheitert.
Fehler Nr. 2: Nach jeder Änderung an einer gemanagten Entität „sicherheitshalber“ save aufrufen.
Im gemanagten Ablauf ist das normalerweise überflüssig: Dirty Checking erledigt seine Arbeit auch ohne save. Dabei festigt ein überflüssiger save eine falsche Gewohnheit und verschlechtert die Lesbarkeit: Es wird unklar, wo die eigentliche Ursache des UPDATE liegt – im Zustand des Modells oder im „Repository-Ritual“.
Fehler Nr. 3: Den Rückgabewert von save im detached-Ablauf ignorieren.
Wenn das Objekt detached ist, wird save oft zu merge und gibt eine gemanagede Kopie zurück. Nach dem save weiter am alten Objekt zu ändern, ist, als würde man ein Auto nach einem Foto reparieren: Sie tun etwas, aber das reale System ändert sich möglicherweise gar nicht.
Fehler Nr. 4: saveAndFlush als „zuverlässigeren save“ verwenden.
saveAndFlush führt keinen Commit durch. Es zwingt das SQL nur dazu, früher gesendet zu werden. Das kann für Fail-Fast und explizites „Jetzt synchronisieren“ nützlich sein, aber wenn man es reflexartig einsetzt, erhält man mehr frühe Sperren, mehr SQL in der Mitte der Transaktion und eine nervösere Persistenzschicht.
Fehler Nr. 5: Versuchen, mit saveAndFlush Probleme mit Transaktionsgrenzen zu heilen.
Wenn die Transaktion falsch entworfen ist (zu lang, über Schichten verteilt oder lebt einfach „wie es kommt“), werden frühe flushes das Verhalten nicht korrigieren – sie machen es nur unvorhersagbarer. Die Grenze der Arbeitseinheit sollte auf dem Service liegen, und saveAndFlush ist ein Werkzeug zur punktuellen Steuerung des Synchronisationszeitpunkts, kein Krückstock für die Architektur.
GO TO FULL VERSION