1. Hauptregel: Äußerer public-Eintritt
Kurz gesagt, die funktionierende Faustregel lautet: @Transactional setzen wir auf den externen public-Methode des Service, die den Use Case abbildet. Genau an dieser Grenze kann Spring vor dem Methodenaufruf eine Transaktion öffnen und danach abschließen. Wenn der Aufruf diesen Eintrittspunkt umgeht, hat die Annotation keine Chance zu wirken – und hieraus entstehen fast alle typischen Fehler.
Daher ist es hilfreich, nicht eine Liste von verstreuten Caveats im Kopf zu haben, sondern ein einziges Bild: Der Controller, Runner oder ein anderer Bean ruft die public-Methode von CatalogService/InventoryService auf; das Spring-Proxy sieht diesen Einstieg und umhüllt ihn mit einer Transaktion. Private Helfer, self-invocation und ein mit new erstelltes Objekt umgehen diese Grenze.
Wenn Sie jemals das Gefühl hatten: „Ich habe eine Annotation hinzugefügt, aber die Datenbank verhält sich, als gäbe es sie nicht“ – Sie sind nicht allein. @Transactional ist kein Compiler-Befehl und kein „Modus“ einer Methode. Es handelt sich um ein Infrastrukturverhalten, das Spring von außen hinzufügt – und das nicht immer und überall.
Der Hauptgedanke: Spring „einbettet“ keine Transaktion in die Methode hinein. Stattdessen erzeugt es einen Wrapper um den Methodenaufruf. Stellen Sie sich Ihren Service als ein Büro vor, die Transaktion als Eintrittskarte. Wenn Sie durch den Haupteingang kommen, gibt der Wachmann die Karte aus. Wenn Sie durchs Fenster klettern (self-invocation), sieht der Wachmann Sie gar nicht – und es gibt keine Karte.
2. Spring-Proxy: Aufrufschema
Solange wir @Service, @Repository und @Transactional schreiben, kann man leicht denken, Spring würde „magisch“ das Verhalten unserer Klasse ändern. Tatsächlich macht Spring etwas viel Bodenständigeres: Statt Ihren echten Service-Objekt an alle auszuliefern, gibt es ein Wrapper-Objekt zurück. Dieser Wrapper heißt Proxy und verhält sich wie eine „Sekretärin des Chefs“: Zum Chef gelangt man nur durch die Sekretärin.
Für uns lautet die praktische Faustregel aus diesem Schema: Externer Code tritt in die public-Methode des Service ein, und alles, was innerhalb passiert – Helfer-Methoden, Repositories, Prüfungen – lebt bereits innerhalb einer geöffneten Transaktion.
Schematisch sieht ein normaler Aufruf so aus:
flowchart TD
C["Controller / anderer Bean"] --> P["Spring Proxy"]
P -->|before| TX["Transaktion öffnen"]
P --> S["Echter CatalogService"]
S --> R["Repository"]
R --> DB["(PostgreSQL)"]
S --> P
P -->|after| END["Commit / Abschluss"]
Der Proxy führt drei wichtige Dinge „um“ den Aufruf herum aus: Vor dem Methodenaufruf öffnet er die Transaktion, dann ruft er die echte Methode auf, dann schließt er die Transaktion. Für uns reicht dieses Bild völlig aus – ohne Eintauchen in die AOP-Interna.
In den Begriffen unseres Projekts shop-data-jpa (Java 25 + Spring Framework 7 + Spring Boot 4) ist die Logik: Der Controller (falls als dünner Adapter vorhanden) oder ein anderer Service ruft die öffentliche Methode von CatalogService auf. Tatsächlich landet er aber nicht im „echten“ CatalogService, sondern im Proxy, und der Proxy entscheidet, ob die Transaktion aktiviert wird.
Minibeispiel (im Sinne von „Aufruf kommt von außen – Proxy arbeitet“):
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CatalogService {
@Transactional
public void renameCategory(Long categoryId, String newName) {
// Wichtig: Diese Methode muss VON AUSSEN über das Spring-Proxy aufgerufen werden.
// Dann öffnet der Proxy DAVOR die Transaktion und führt nachher commit/rollback durch.
// Wird die Methode direkt auf dem Objekt aufgerufen (via new), gibt es keine Transaktion.
}
}
Die entscheidende Bedingung liegt nicht in der Annotation, sondern darin, dass der Aufruf von außen kommt, durch den Spring-Container. Der Proxy ist kein Gedankenleser: Er sieht nur die Aufrufe, die durch ihn hindurchgehen.
3. Self-invocation und Proxy-Umgehung
Aus dieser Regel ergibt sich self-invocation fast automatisch. Dabei handelt es sich um die Situation, wenn eine Methode einer Klasse eine andere Methode derselben Klasse über this aufruft. Und hier beginnt Magie … oder besser gesagt, sie endet.
Stellen wir uns vor, wir haben in CatalogService zwei Methoden: eine öffentliche „szenariobasierte“, die zweite ebenfalls öffentlich, und wir haben naiv den Wunsch, die zweite solle „in einer Transaktion“ sein. Das wirkt logisch, funktioniert aber nicht so, wie ein Neuling erwartet.
Schlechtes Beispiel: Annotation auf der „inneren“ Methode
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CatalogService {
public void createDemoCategory() {
// Aufruf innerhalb derselben Klasse = self-invocation.
// Der Proxy ist hier nicht beteiligt, da es ein normaler Methodenaufruf auf this ist.
saveCategory("books", "Books");
}
@Transactional
public void saveCategory(String code, String name) {
// Erwarten eine Transaktion, aber beim Aufruf aus createDemoCategory() wird es keine geben,
// weil @Transactional die Transaktion nur bei Einstieg über den Proxy aktiviert.
}
}
Warum „wird es keine geben“? Weil createDemoCategory() saveCategory() auf demselben Objekt aufruft. Der Aufruf geht also nicht durch den Proxy, sondern direkt: „Im Büro ruft der Chef sich selbst über das interne Telefon an“ – die Sekretärin ist nicht beteiligt.
Um dies als System zu verstehen, ist es hilfreich, sich zwei verschiedene Wege vorzustellen:
sequenceDiagram
participant Ctrl as "Controller"
participant Proxy as "CatalogService Proxy"
participant Svc as "CatalogService (real)"
Ctrl->>Proxy: "createDemoCategory()"
Proxy->>Svc: "createDemoCategory()"
Svc->>Svc: "this.saveCategory() (self-invocation)"
Note over Svc: "Proxy nimmt NICHT teil → @Transactional auf saveCategory wird nicht angewendet"
Ja, das Repository darunter kann trotzdem eine Transaktion für save(...) erstellen (Spring Data JPA hat eigene transactional defaults), aber die Grenze wird nicht dort sein, wo Sie sie erwarten. Und das ist das Hauptproblem: Sie denken „ich habe eine Transaktion auf saveCategory“, aber in Wirklichkeit ist sie nicht da – und wenn Sie morgen einen zweiten Schritt hinzufügen (ein weiteres Repository, einen weiteren Eintrag), erhalten Sie eine Reihe kleiner, isolierter Transaktionen statt einer einzigen Unit of Work.
Gutes Beispiel: Transaktion am äußeren Entry-Point
Der richtige Lern-Standard für diesen Tag ist die Transaktion auf der äußeren öffentlichen Methode, die den Einstieg in den Use Case darstellt.
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CatalogService {
@Transactional
public void createDemoCategory() {
// Der Aufruf kommt VON AUSSEN (z. B. aus dem Controller/anderem Bean) -> durch den Proxy.
// Der Proxy öffnet die Transaktion hier, an der Use-Case-Grenze.
saveCategory("books", "Books");
}
private void saveCategory(String code, String name) {
// Helper: wird bereits innerhalb der geöffneten Transaktion aufgerufen
// (wir versuchen nicht, aus der Helper-Methode eine separate „Transaktionsgrenze“ zu machen).
}
}
Die Idee ist einfach: createDemoCategory() wird von außen über den Proxy aufgerufen, der Proxy öffnet die Transaktion, und dann können beliebig viele Helfer-Methoden aufgerufen werden – sie nehmen an der bereits geöffneten Transaktion teil. Wir versuchen nicht, die Transaktion auf einer inneren Methode zu „aktivieren“ – wir aktivieren sie an der Use-Case-Grenze.
Wenn Sie dies als Regel formulieren möchten, lautet sie: Versehen Sie das mit der Transaktion, was der Einstiegspunkt der Geschäftsoperation ist, nicht das, was ein unterstützender Schritt innerhalb dieser Operation ist.
4. Einschränkungen des Proxys: private, final, new
Wenn ein Student zum ersten Mal vom Proxy erfährt, vermutet er meist, Spring sei so eine Art Theater, in dem die Hälfte der Schauspieler Masken trägt. Und ehrlich gesagt ist das gar nicht so weit von der Wahrheit entfernt. Aus dieser „Masken“-Natur des Proxys ergeben sich jedoch einige sehr praktische Einschränkungen, die man besser jetzt sieht als später in der Produktion, unter den Klängen fallender Tests.
private-Methode – kein Transaktionsgrenzpunkt
Wenn eine Methode private ist, kann der Proxy sie physisch nicht als separate Operation „abfangen“: Eine private Methode kann nicht durch Vererbung überschrieben werden, und der Proxy kann keine „vorher/nachher“-Logik um sie herum einfügen, als wäre sie eine öffentliche API.
Hier ein Beispiel, das „klug“ aussieht, aber im Sinne nutzlos ist:
import org.springframework.transaction.annotation.Transactional;
public class InventoryService {
public void updateStock(Long productId) {
// Von außen ist nur updateStock() sichtbar.
// Wenn hier keine Transaktion am Entry-Point steht, wird es so kommen, wie es kommt.
doUpdate(productId);
}
@Transactional
private void doUpdate(Long productId) {
// Wird nicht als eigenständige Transaktionsgrenze funktionieren:
// Private Methode kann der Proxy nicht abfangen.
}
}
Der richtige Ansatz für uns in diesem Kurs: @Transactional auf die öffentliche Methode des Service, und private für Helfer-Logik innerhalb der bereits geöffneten Transaktion.
final-Methode und final-Klasse – ebenfalls schlechte Freunde des Proxys
Dieses Thema kann man lange erklären, aber für uns reicht ein praktischer Gedanke: Ein Proxy wird oft als „Unterklasse“ erzeugt, die Methoden überschreibt und Verhalten hinzufügt. final verbietet das Überschreiben.
In Java-Projekten kommt das nicht so häufig vor, aber dennoch: Machen Sie transaktionale Services nicht zu final-Klassen und transaktionale Methoden nicht final. Wenn Sie die „API schließen“ möchten, diskutieren Sie das lieber auf Architekturebene und auf Paketebene, anstatt zu versuchen, Spring mit Vererbungsregeln zu besiegen.
„Ich habe den Service mit new erstellt – warum gibt es keine Transaktionen?“
Dies ist eine der häufigsten Denkfallen. @Transactional funktioniert nur auf einem Spring-Bean, also einem Objekt, das im Container lebt und (falls erforderlich) mit einem Proxy umhüllt ist. Wenn Sie irgendwo new CatalogService(...) machen, erstellen Sie ein gewöhnliches Java-Objekt, ohne Beteiligung von Spring, ohne Proxy, ohne Transaktionsinfrastruktur.
Ein sehr trauriges (aber realistisches) Anti-Beispiel:
public class DemoRunner {
public void run(CategoryRepository categoryRepository,
ProductRepository productRepository) {
// Objekt von Hand erstellt -> außerhalb des Spring-Containers -> kein Proxy und kein @Transactional
CatalogService service = new CatalogService(categoryRepository, productRepository);
service.renameCategory(1L, "New"); // @Transactional wird nicht greifen
}
}
Wenn Sie Code benötigen, der beim Start der Anwendung ausgeführt wird, muss auch dieser ein Spring-Komponente sein (z. B. @Component) und den Service per DI erhalten. Dann erfolgt der Aufruf über den Proxy.
5. Controller und Transaktion
Nun übertragen wir dieselbe Regel auf die Schichten. In der geschichteten Architektur gehört die Transaktionsgrenze zum Use Case, und der Use Case lebt im Service. Daher sollte der Controller nicht zum Besitzer der Transaktion werden, nur weil er als erster die HTTP-Anfrage erhält.
Die Idee, die wir heute festhalten: Der Controller delegiert, der Service führt den Use Case aus, das Repository gibt Zugriff auf Daten. Die Transaktionsgrenze ist Teil des Use Case, also lebt sie natürlicherweise im Service.
Wie ein „dünner“ Controller aussieht (richtiger Standard)
Selbst wenn die Web-Schicht in unserem shop-data-jpa ein minimaler Smoke-Adapter bleibt, sollte sie dennoch dünn sein:
import java.math.BigDecimal;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final CatalogService catalogService;
public ProductController(CatalogService catalogService) {
// DI: Der Service kommt aus dem Spring-Container (und kann daher proxyiert sein)
this.catalogService = catalogService;
}
@PostMapping
public Long create(@RequestParam Long categoryId,
@RequestParam String sku,
@RequestParam String name,
@RequestParam BigDecimal price) {
// Der Controller nimmt nur die Eingabe entgegen und delegiert an den Use Case.
// Die Transaktion sollte im Service leben, nicht hier.
return catalogService.createProduct(categoryId, sku, name, price);
}
}
Hier öffnet der Controller keine Transaktion. Er weiß nicht, wie viele Schritte in createProduct stecken. Er delegiert einfach an den Service, und der Service entscheidet, was die Unit of Work ist.
@Transactional am Controller – schlechter Standard
Manchmal erscheint es verlockend: „Hier habe ich den Eingang der Anfrage, also fange ich die Transaktion hier an.“ Technisch kann das funktionieren, aber architektonisch ist es fast immer eine schlechte Angewohnheit.
Erstens beginnt die Transaktion zu früh und endet zu spät: Sie halten die Transaktion offen, während der Controller Parameter parst, die Antwort erstellt, vielleicht unnötige Umwandlungen macht. Zweitens verwischt es die Verantwortlichkeiten: Die Geschäftsoperation lebt im Service, die Transaktion im Controller. Liest den Code und versteht nicht: Wo ist wirklich der „Beginn des Use Case“?
Anti-Beispiel (so sollte man es nicht machen):
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
@RestController
public class BadProductController {
private final ProductRepository productRepository;
public BadProductController(ProductRepository productRepository) {
// Das Repository ist ebenfalls ein Spring-Bean, aber der Controller sollte nicht der Ort der "Unit of Work" sein.
this.productRepository = productRepository;
}
@Transactional
@PostMapping("/bad/products/{id}/deactivate")
public void deactivate(@PathVariable Long id) {
// Der Controller verwaltet die Daten direkt -> Vermischung von Web-Schicht und Daten-Schicht
Product p = productRepository.findById(id).orElseThrow();
p.setStatus(ProductStatus.INACTIVE);
productRepository.save(p);
}
}
Ja, das „funktioniert“. Aber es ist genau der Lernweg zum „Controller weiß zu viel“. Wir möchten nicht, dass die Web-Schicht direkt die Daten-Schicht steuert. Selbst in einem Übungsprojekt.
6. Transaktionsprüfung: Logs und Diagnose
Wenn Sie lernen, ist es hilfreich, eine Möglichkeit zu haben, „die Realität zu berühren“. Bei Transaktionen ist dies besonders wichtig, weil sie unsichtbar sind: Es erscheint kein magischer Rahmen im Code, und die Datenbank sendet keine Postkarte mit dem Text „Lieber Entwickler, eine Transaktion wurde gestartet“. Daher brauchen wir eine einfache Möglichkeit, sicherzustellen, dass der Proxy tatsächlich gearbeitet hat.
Die verständlichste Methode auf dieser Ebene ist, das Transaktionslogging einzuschalten und zu sehen, wo sie beginnen. Dies ist keine Produktionspraxis („wir schalten forever TRACE ein“), sondern eine Lern-Taschenlampe: Leuchten, verstehen, ausschalten.
Transaktionslogging in application-dev.yml aktivieren
Halten Sie die Konfiguration minimal und aktivieren Sie sie nur im Dev-Profil:
logging:
level:
# Lern-Diagnose: zeigt Start/Ende von Transaktionen und Teilnahme an bestehenden
org.springframework.transaction: TRACE
Danach sehen Sie beim Aufruf einer transaktionalen Methode Logs wie „Transaktion erstellen / teilnehmen / abschließen“. Die genauen Formulierungen können variieren, aber die allgemeine Bedeutung ist dieselbe.
Kleiner „Hand-Check“ innerhalb der Methode (reines Lernwerkzeug)
Manchmal möchte man direkt aus dem Code überprüfen: „Bin ich innerhalb einer Transaktion oder nicht?“. Für die Lern-Diagnose können Sie TransactionSynchronizationManager verwenden.
import org.springframework.transaction.support.TransactionSynchronizationManager;
public class TxDebug {
public static void printTxState() {
// Prüft genau die "echte" Transaktion im aktuellen Ausführungsthread
boolean active = TransactionSynchronizationManager.isActualTransactionActive();
// Wichtig: Dies ist ein Labor-Print für das Lernen, keine dauerhafte Praxis für die Produktion
System.out.println("tx active = " + active); // tx active = true/false
}
}
Und rufen Sie das in der Service-Methode auf. Es ist wichtig, dies als Laborwerkzeug zu betrachten: In einem normalen Projekt drucken wir so etwas nicht in jeder Methode aus. Aber um zu verstehen, wo der Proxy die Transaktion tatsächlich aktiviert hat, ist es nützlich.
Wenn Sie diese Technik auf das Beispiel mit self-invocation anwenden, können Sie den Unterschied sehen: Die äußere öffentliche Methode mit @Transactional gibt true zurück, während der „innere“ Aufruf der annotierten Methode über this die Transaktion nicht selbst aktiviert.
Das reicht bereits, um eine ordentliche Service-Schicht aufzubauen: Eine äußere public-Methode des Service wird zum Einstiegspunkt, und innerhalb leben alle notwendigen Schritte der Unit of Work.
7. Typische Fehler bei der Arbeit mit @Transactional
Fehler Nr. 1: Erwarten, dass @Transactional eine „magische Methodeneinstellung“ ist, die auch beim Aufruf innerhalb derselben Klasse wirkt.
Diese Erwartung tritt fast bei jedem auf: „Wenn eine Annotation vorhanden ist, dann muss die Transaktion da sein.“ Aber in Spring wird die Transaktion durch einen Proxy-Wrapper hinzugefügt, und der interne Aufruf this.someMethod() wird vom Proxy nicht gesehen. Daher ergibt die Annotation auf einer „inneren“ Methode oft nicht das erwartete Verhalten.
Fehler Nr. 2: @Transactional auf private-Methoden setzen und hoffen, dass Spring sie „anwendet“.
Auch wenn die IDE die Annotation hübsch hervorhebt, bedeutet das nicht, dass sie zu einer Transaktionsgrenze wird. Eine private Methode ist ein internes Detail der Klasse. Wenn Sie eine Operationsgrenze möchten, setzen Sie sie auf den öffentlichen Service-Einstiegspunkt; private Methoden bleiben Helfer-Logik.
Fehler Nr. 3: Die Transaktion im Controller öffnen, „weil es der Beginn der Anfrage ist“.
Der Controller ist die Eingangsschicht, nicht der Besitzer der Geschäftsoperation. Wenn die Transaktion im Controller beginnt, vermischen Sie Web-Verantwortung und Daten-Verantwortung, und die Use-Case-Grenze wird implizit. In einem Übungsprojekt festigt dies eine schlechte Angewohnheit, die später in einer echten Codebasis sehr stört.
Fehler Nr. 4: Den Service von Hand mit new erstellen und sich wundern, dass die Annotationen nicht funktionieren.
Annotationen wie @Transactional und @Service funktionieren nur, wenn das Objekt vom Spring-Container erstellt und verwaltet wird. Wenn Sie den Container umgehen, umgehen Sie auch den Proxy. Dies passiert besonders häufig in „schnellen Demos“ und in Tests, in denen man den „Kontext nicht hochfahren“ möchte.
Fehler Nr. 5: Versuchen, self-invocation mit „tricky Tricks“ zu heilen, anstatt den Entry-Point richtig zu setzen.
Manchmal wird im Internet geraten: „injiziere dich selbst in dich“, „hole den Proxy aus dem Kontext“, „rufe die Methode über AopContext auf“. Das alles sieht nach schwarzer Magie aus und verschlechtert in einem Übungsprojekt nur das Verständnis. Der richtige Basisweg ist einfacher: Transaktion auf dem öffentlichen Entry-Point der Service-Methode, und innere Schritte sind normale Methoden ohne den Versuch, die Infrastruktur separat einzuschalten.
GO TO FULL VERSION