1. Transaktionale Vorgaben des Repositories
Wenn Spring Data JPA ein Mensch wäre, würde es sagen: „Ich habe bereits etwas für dich getan, aber es ist nicht sicher, ob es genau das ist, was du erwartet hast.“ Die transaktionalen Vorgaben des Repositories sind wie die Werkseinstellungen eines Telefons: Sie retten vor wirklich traurigen Szenarien, aber wenn Sie nicht wissen, wo der „Nicht stören“-Modus eingeschaltet wird, kann es passieren, dass Sie mitten in der Nacht einen Anruf erhalten – in Form von unerwartetem Transaktionsverhalten.
Propagation hat bereits gezeigt, dass das Schicksal eines Use Cases normalerweise auf Service-Ebene entschieden wird. Aber innerhalb dieser Grenze leben trotzdem konkrete Repository-Aufrufe: manchmal innerhalb einer bereits geöffneten Transaktion, manchmal separat. Daher ist die nächste praktische Frage sehr bodenständig: Was macht repository proxy überhaupt standardmäßig und wo enden diese Defaults?
Aus praktischer Sicht ist es für uns wichtig, nur eine Sache zu verstehen: Nicht alle Methoden des Repositories sind standardmäßig gleich „transaktional“. Und das wird besonders deutlich, wenn Sie die „geerbten CRUD-Methoden“ (findById, save, deleteById …) mit Ihren „deklarierten Abfragemethoden“ (findByStatus(...), @Query(...), @Modifying …) vergleichen.
Um keine Verwirrung zu stiften, ist es hilfreich, sich das Repository als ein Proxy-Objekt vorzustellen, durch das Spring die Aufrufe leitet:
flowchart LR
S[Service] --> R[Repository proxy]
R --> T[Transaction interceptor]
T --> E[Execution: CRUD impl or Query execution]
E --> DB[(PostgreSQL)]
Der Service ruft eine Methode des Repositories auf. Das Repository ist ein Bean, aber tatsächlich sitzt dort ein Proxy, der vor der Ausführung entscheidet: „Muss eine Transaktion geöffnet werden? Mit readOnly oder ohne? Oder nehmen wir an einer bereits geöffneten teil?“
2. CRUD-Methoden: Defaults von Spring Data JPA
Die geerbten CRUD-Methoden sind diejenigen, die Sie „im Paket“ erhalten, nur weil Ihr Interface JpaRepository erweitert. Sie haben sie nicht geschrieben, aber Sie nutzen sie täglich: findById, findAll, existsById, save, deleteById und so weiter. Das Wichtigste hier: Für diese Methoden hat Spring Data JPA bereits transaktionale Einstellungen festgelegt, und deshalb verhalten sie sich recht vorhersagbar.
Ein typisches Modell (in didaktischer Vereinfachung, ohne in die Quellen einzutauchen) sieht so aus: Leseoperationen laufen normalerweise in einer Transaktion mit readOnly = true, und Schreiboperationen in einer normalen Transaktion. Das heißt, die CRUD-Methoden haben bereits ein „eingebautes“ @Transactional, und wenn Sie sie außerhalb einer Service-Transaktion aufrufen, kann das Repository trotzdem eine eigene öffnen.
Um ein Gefühl „mit den Fingerspitzen“ zu bekommen, betrachten wir zwei Gruppen von CRUD-Methoden in einer Tabelle:
| Kategorie | Beispiele für Methoden von JpaRepository | Idee des transactional Defaults |
|---|---|---|
| Lesen | findById, findAll, existsById, count | Transaktion zum Lesen, normalerweise readOnly = true |
| Schreiben | save, deleteById, delete, saveAll | normale Schreibtransaktion (readOnly = false) |
In unserem Projekt shop-data-jpa bedeutet das, dass selbst wenn Sie eine „schnelle Überprüfung“ aus einem temporären Code heraus durchführen (was für sich genommen fragwürdig ist), die CRUD-Methoden trotzdem oft in einem mehr oder weniger korrekten transaktionalen Rahmen ausgeführt werden.
Ein winziges Beispiel für ein Repository im Katalog:
import org.springframework.data.jpa.repository.JpaRepository;
// Repository ohne eigene Methoden: CRUD kommt "out of the box" von JpaRepository
public interface ProductRepository extends JpaRepository<Product, Long> {
// Hier ist nichts, aber findById/save/deleteById sind bereits verfügbar und haben eine standardmäßige Transaktionalität
}
In diesem Interface gibt es keine einzige Methode – aber findById() und save() sind bereits verfügbar, und sie haben bereits eine transaktionale Semantik „standardmäßig“ von Spring Data JPA.
Und genau hier beginnt die häufigste Anfängerfalle: „Wenn CRUD-Methoden bereits transaktional sind, dann werden meine Methoden auch so sein.“ Falsch. Und weiter werden wir genau besprechen, warum.
3. Die Einstellungen der CRUD-Methoden überschreiben
Manchmal reichen die „Werkseinstellungen“ einer CRUD-Methode nicht aus. Zum Beispiel möchten Sie ein Timeout setzen (um nicht auf eine „ewige Abfrage“ zu warten), oder Sie möchten die Absicht des Aufrufs deutlich machen: „Ja, diese Methode ist immer noch Nur-Lesen, aber sie hat besondere Anforderungen.“ In Spring Data JPA gibt es dafür einen einfachen Trick: Redeclare – die geerbte Methode in Ihrem Repository-Interface neu deklarieren und die gewünschten Annotationen darauf setzen.
Das sieht für Anfänger etwas seltsam aus: „Ich implementiere die Methode doch nicht, warum sollte ich sie erneut deklarieren?“ Die Antwort ist einfach: Sie deklarieren keine Implementierung, sondern zusätzliche Metadaten, die Spring auf Proxy-Ebene liest und anwendet.
Beispiel: Sie möchten findAll() zeitlich begrenzen (z. B. für einen Admin-Bildschirm, der nicht ewig hängen soll):
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;
public interface CategoryRepository extends JpaRepository<Category, Long> {
// Redeclare: Wir schreiben keine Implementierung, aber wir setzen transaktionale Metadaten
@Override
@Transactional(timeout = 5) // Timeout in Sekunden: Wenn die Abfrage "hängt", warten wir nicht endlos
List<Category> findAll();
}
Ja, es ist immer noch findAll(). Ja, Sie implementieren es nicht. Aber jetzt sieht Spring: „Für diesen Aufruf gilt ein Timeout von 5 Sekunden.“
Es ist wichtig, Redeclare nicht in „Annotationsspam“ zu verwandeln. Wenn Sie anfangen, wahllos alles neu zu deklarieren, wird das Repository wie ein Weihnachtsbaum aussehen, an dem mehr Kugeln als Nadeln hängen. Redeclare ist ein Werkzeug für gezielte Einstellungen, wenn Sie wirklich einen Grund haben.
Noch ein Punkt: Redeclare hilft speziell bei geerbten CRUD-Methoden, weil diese eine Basisimplementierung und Basiseinstellungen haben. Sobald Sie jedoch eigene Abfragemethoden schreiben, ändert sich das Bild – und genau dort beginnen die Überraschungen.
4. Deklarierte Abfragemethoden: separate Regeln
Deklarierte Abfragemethoden sind alle Methoden, die Sie selbst hinzugefügt haben: abgeleitete Abfragen (derived queries) wie findByStatus(...), JPQL über @Query, und die separate Gruppe der modifying-Abfragen über @Modifying. Für Anfänger sehen sie aus wie „ganz normale Repository-Methoden“, aber intern führt Spring Data sie anders aus – und daher bekommen sie standardmäßig nicht dieselben transactional defaults wie CRUD.
Das ist wichtig: Wenn Sie auf das Repository-Interface schauen, sieht Ihr Gehirn einfach eine Reihe von Methoden. Aber Spring sieht zwei verschiedene Welten: die Welt der eingebauten CRUDs und die Welt der deklarierten Abfragemethoden. Und wenn bei CRUD die Transaktionalität bereits aus der Basisimplementierung konfiguriert ist, kann es bei den Abfragemethoden sein, dass dieses „Standardgeschenk“ fehlt.
Schauen wir uns ein Beispiel an, das man sehr leicht im Katalog schreiben kann:
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
// Derived query: Spring Data erstellt die Abfrage selbst anhand des Methodennamens
List<Product> findByStatus(ProductStatus status);
}
findByStatus(...) ist eine derived query method. Sie „sieht aus“ wie eine Lesemethode, aber ihre Transaktionalität muss nicht explizit konfiguriert sein, solange Sie nicht selbst Regeln festlegen.
Und genau hier entsteht eine unangenehme Asymmetrie: Ihr Code im Service kann zwei Lesevorgänge hintereinander ausführen – einen über findAll() (CRUD), den anderen über findByStatus() (deklarierte Methode) – und sie werden möglicherweise unter unterschiedlichen transaktionalen Bedingungen ausgeführt, wenn Sie dies nicht auf Service-Ebene steuern oder keine Richtlinie auf dem Repository festlegen.
Für uns ist die wichtigste Schlussfolgerung nicht philosophisch, sondern praktisch: Wenn Sie möchten, dass das Lesen über Abfragemethoden vorhersagbar ist, geben Sie ihm einen vorhersagbaren transaktionalen Rahmen. Die beliebteste Variante ist @Transactional(readOnly = true).
5. Nur-Lesen-Modus für das Lesen des Repositories
Wenn Sie ein Repository entwerfen, möchten Sie fast immer, dass das Lesen „Lesen“ ist, also ohne versehentliches Schreiben und ohne unnötigen schweren Infrastrukturaufwand. Und Sie möchten, dass diese Absicht direkt im Code sichtbar ist. Daher ist ein sehr verbreitetes Muster in Spring Data JPA, @Transactional(readOnly = true) auf das Repository (oder auf bestimmte Lesemethoden) zu setzen, damit die Abfragemethoden nicht „wie es kommt“ leben.
Es gibt zwei praktische Stile. Der erste: Setzen Sie @Transactional(readOnly = true) auf die gesamte Repository-Schnittstelle, und dann fallen alle Lese-Abfragemethoden in einen verständlichen Modus:
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;
@Transactional(readOnly = true) // Standardrichtlinie: Repository-Operationen gelten als Lesen
public interface ProductRepository extends JpaRepository<Product, Long> {
// Lesen: bleibt in der read-only-Transaktion (falls keine externe Transaktion mit anderen Einstellungen vorhanden ist)
List<Product> findByStatus(ProductStatus status);
}
Diese Variante gefällt, weil sie den „Ton“ des Repositories vorgibt: „Standardmäßig ist dies ein Nur-Lesen-Zugriff“. Dabei bleiben CRUD-Schreiboperationen (save, delete) trotzdem Schreiboperationen, weil sie eine eigene Semantik haben und nicht zufällig zu Nur-Lesen werden sollten, nur weil Sie das Interface markiert haben.
Der zweite Stil ist die Annotation einer bestimmten Methode, besonders wenn das Repository gemischt ist oder Sie keine globale Richtlinie festlegen möchten:
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;
public interface StockItemRepository extends JpaRepository<StockItem, Long> {
// Gezielte Einstellung: Diese Methode wird garantiert als read-only ausgeführt
@Transactional(readOnly = true)
List<StockItem> findByAvailableQuantityGreaterThan(int minQty);
}
Welcher Stil ist besser? In einem Übungsprojekt ist meist der erste einfacher: „Repository standardmäßig lesend“. Aber wichtiger als der Stil ist die Einheitlichkeit. Wenn Sie in einem Repository manchmal @Transactional(readOnly = true) setzen, manchmal nicht, und manchmal ist es auf dem Service, dann werden Sie in einem Monat diesen Code selbst wie einen Krimi lesen: „So, war hier eine Transaktion? Und readOnly? Und warum unterscheidet es sich?“
Und hier ist der wichtige Gedanke des Tages: Die transaktionalen Vorgaben des Repositories sind der Hintergrund. Sie helfen, ersetzen aber nicht die architektonische Entscheidung über die Grenze des Use Cases, die Sie normalerweise im Service treffen. Das Repository ist ein „Zugriffswerkzeug“, nicht der „Eigentümer der Geschäftsoperation“.
6. @Modifying: Schreibmethoden des Repositories
Wenn eine Abfragemethode aufhört, nur zu lesen, und beginnt, Daten zu ändern (update/delete), überschreiten Sie buchstäblich eine Grenze: Jetzt handelt es sich um ein Schreibszenario, das Schreibregeln haben muss. Und hier taucht ein häufiger „Eigentor“-Fehler auf: Der Entwickler markiert das Repository als @Transactional(readOnly = true) und fügt dann eine @Modifying-Methode hinzu … und bekommt „Warum verhält es sich seltsam?“
Die richtige Disziplin ist einfach: Eine modifying-Abfragemethode muss explizit schreibtransaktional sein. Das heißt, Sie setzen @Modifying und darüber @Transactional ohne readOnly = true (oder mit readOnly = false, wenn Sie die Absicht betonen möchten).
Beispiel für unseren Katalog: Massenhaft (oder gezielt) den Status eines Produkts über JPQL-Update ändern:
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;
@Transactional(readOnly = true) // Standardmäßig ist das Repository "lesend"
public interface ProductRepository extends JpaRepository<Product, Long> {
// Modifying-Abfrage: Dies ist kein Select mehr, es ändert Daten
@Modifying
// WICHTIG: Verlasse den read-only-Modus für diese Methode
@Transactional
// JPQL-Update (Textblock ist lesbarer, besonders wenn die Abfrage wächst)
@Query("""
update Product p
set p.status = ?2
where p.id = ?1
""")
int updateStatus(Long productId, ProductStatus status);
}
Hier sieht man sofort drei wichtige Signale, die auch mit „müden Augen“ nach fünf Stunden Debuggen gut lesbar sind:
Erstens ist das Repository insgesamt standardmäßig read-only, das heißt, alle normalen Lesevorgänge und derived Queries sind im sicheren Modus. Zweitens ist die konkrete Methode explizit als modifying markiert, es ist also kein „weiterer Select“. Drittens ist die Methode explizit transaktional im Schreibmodus, das heißt, sie versucht nicht, sich als Lesevorgang auszugeben.
Es ist auch nützlich, sich zu merken, dass eine write-query-Methode fast immer innerhalb einer Transaktion ausgeführt werden sollte. Ja, wenn Sie diese Methode aus einem Service-@Transactional aufrufen, wird sie an der äußeren Transaktion teilnehmen. Aber eine explizite Annotation auf der Repository-Methode macht das Verhalten auch dann vorhersagbar, wenn jemand die Methode versehentlich von woanders aufruft (und solche „Versehen“ passieren in Teams sehr regelmäßig).
readOnly als Hinweis: Was es für das Repository wirklich bedeutet
readOnly = true klingt wie „Schreiben verboten“, aber in Wirklichkeit ist es eher „Ich verspreche, nicht zu schreiben, und du, Infrastruktur, kannst das Lesen optimieren“. Es ist also kein Wachmann mit einem Knüppel, sondern ein Schild „Nicht verschmutzen“ – es ist leicht, es zu befolgen, aber physisch wird es Sie nicht aufhalten. Deshalb ist es wichtig, readOnly nicht in einen magischen Zauberspruch zu verwandeln, der „Sicherheit garantiert“.
Aus praktischer Sicht sind für uns zwei Dinge wichtig. Erstens macht readOnly Ihren Code lesbarer: Sie sehen sofort die Absicht. Zweitens kann readOnly beeinflussen, wie das ORM und der Transaktionsmanager mit der Operation umgehen (z. B. hinsichtlich der Notwendigkeit der Synchronisation von Änderungen). Aber das bedeutet nicht, dass Sie sich auf readOnly als eisernes Schreibverbot verlassen können.
Eine sehr schlechte Idee ist es, Folgendes zu tun:
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CatalogQueryService {
@Transactional(readOnly = true) // Methode wird als "Lesen" deklariert
public void oops(ProductRepository productRepository, Product product) {
// Vertragsverletzung: Innerhalb eines read-only-Szenarios führen wir einen Schreibvorgang durch
productRepository.save(product); // "Na ja, ich hab's versehentlich getan..."
}
}
Technisch gesehen kann dies „sofort nicht abstürzen“, aber der Sinn Ihrer Architektur ist bereits gebrochen: Die Methode hat versprochen, nur zu lesen, und beginnt zu schreiben. Im Team wird daraus das Spiel „Rate, wo die Schreiboperation ist“, und die transaktionalen Einstellungen werden zur Lotterie.
Im Kontext von Repositories ist die Regel sehr einfach und lebensnah: Wenn eine Methode liest, soll sie wie ein Lesevorgang aussehen; wenn sie schreibt, soll sie wie ein Schreibvorgang aussehen. readOnly ist eines der Werkzeuge, die helfen, diese Ehrlichkeit zu wahren.
7. Typische Fehler bei Repository-Transaktionen
Fehler Nr. 1: Zu glauben, dass alle Methoden des Repositories „gleich transaktional“ sind.
Das passiert fast allen Anfängern: Das Repository-Interface sieht aus wie eine gleichmäßige Liste von Methoden, und es scheint, dass Spring sie alle gleich behandelt. In der Praxis haben CRUD-Methoden ihre eigenen Defaults, und deklarierte Abfragemethoden haben leicht keine explizite transaktionale Richtlinie. Dies wird durch Disziplin geheilt: entweder eine Transaktion auf dem Service als Owner des Use Cases oder explizite Annotationen auf den Abfragemethoden.
Fehler Nr. 2: Derived Queries und @Query ohne @Transactional(readOnly = true) zu lassen und auf „es regelt sich schon“ zu hoffen.
Selbst wenn „es sich heute nicht selbst zerstört hat“, haben Sie eine Zeitbombe für die Zukunft hinterlassen: Das Verhalten der Abfrage hängt davon ab, wer und wie sie aufgerufen hat. In einem Übungsprojekt ist das besonders schädlich, weil Sie den Kausalzusammenhang zwischen Code und Verhalten verlieren.
Fehler Nr. 3: Das Repository als readOnly = true zu markieren und dann eine @Modifying-Methode ohne Überschreibung hinzuzufügen.
Das sieht nach einer Kleinigkeit aus, aber sinngemäß ist es ein Konflikt: Sie sagen „ich lese“ und gleichzeitig „ich aktualisiere“. Der richtige Stil ist ein method-level @Transactional für modifying Abfragen, damit es explizit den read-only-Modus verlässt.
Fehler Nr. 4: readOnly = true als „eisernes Schreibverbot“ zu betrachten.
readOnly ist ein Hinweis, kein Schloss an der Tür. Wenn Sie das Schreiben auf architektonischer Ebene verbieten möchten, tun Sie das durch die Code-Struktur: Sie trennen Lese- und Schreib-Use-Cases, mischen sie nicht in einer Methode, und verlassen sich nicht darauf, dass die Annotation Sie daran hindert, save() aufzurufen.
Fehler Nr. 5: Versuchen, Service-Probleme mit Annotationen auf dem Repository zu lösen.
Das Repository ist die Datenzugriffsschicht, nicht der Regisseur der Geschäftsoperation. Es ist nützlich, die transaktionalen Defaults des Repositories zu kennen, aber sie ersetzen nicht die Tatsache, dass die Grenze eines Use Cases normalerweise im Service lebt. Wenn Sie anfangen, die Annotationen auf dem Repository zu „verdrehen“, um das Chaos in den Services zu kompensieren, verschieben Sie das Chaos normalerweise nur auf eine niedrigere Ebene.
GO TO FULL VERSION