\(.*\)<\/version>.*/\1/')"
+ echo "database: H2 in-memory"
+ echo "transaction manager: JpaTransactionManager (Spring Boot default for JPA)"
+} > docs/output/00-versions.txt 2>&1
+cat docs/output/00-versions.txt
diff --git a/transactions/scripts/env.sh b/transactions/scripts/env.sh
new file mode 100755
index 0000000..dc5aa2c
--- /dev/null
+++ b/transactions/scripts/env.sh
@@ -0,0 +1,51 @@
+#!/usr/bin/env bash
+# Shared environment. Point JAVA_HOME at a JDK 25 (or newer) installation.
+: "${JAVA_HOME:?set JAVA_HOME to a JDK 25+ installation}"
+export PATH="$JAVA_HOME/bin:$PATH"
+MVN="${MVN:-mvn}"
+JAR="target/transactions-1.0.0.jar"
+APP_PORT="${APP_PORT:-8081}"
+LOG="${LOG:-/tmp/transactions-demo.log}"
+
+# Strip machine-specific noise from committed transcripts.
+clean() { grep -v "Picked up JAVA_TOOL_OPTIONS" | grep -v "^OpenJDK 64-Bit Server VM warning"; }
+
+# Reduce a Spring log line to its message, so transcripts diff cleanly between runs.
+tidy() { sed -E 's/^[0-9T:.-]+Z +//; s/^[A-Z]+ +[0-9]+ --- \[[^]]*\] \[[^]]*\] +//; s/ +: /: /'; }
+
+# Start detached and block until it answers. Deliberately NOT setsid: setsid forks when it is
+# not already a process-group leader, so $! would name a process that exits immediately and
+# the JVM would survive every later stop_app -- holding the port, so the next scenario fails
+# to bind and curl answers from the previous one. That reads exactly like the configuration
+# under test having had no effect.
+start_app() {
+ stop_app
+ mkdir -p target
+ nohup java -jar "$JAR" "$@" > "$LOG" 2>&1 < /dev/null &
+ echo $! > target/app.pid
+ for _ in $(seq 1 60); do
+ curl -s -o /dev/null "http://127.0.0.1:${APP_PORT}/tx/silent" 2>/dev/null && return 0
+ kill -0 "$(cat target/app.pid)" 2>/dev/null || { echo "JVM exited during startup:"
+ tail -20 "$LOG"; return 1; }
+ sleep 1
+ done
+ echo "application did not answer"; tail -20 "$LOG"; return 1
+}
+
+# Stop by recorded PID. Never by pattern: `ps | grep transactions` also matches the shell
+# running this script, because that string is on its own command line.
+stop_app() {
+ if [ -f target/app.pid ]; then
+ pid=$(cat target/app.pid)
+ if [ -n "$pid" ] && grep -qa "transactions" "/proc/$pid/cmdline" 2>/dev/null; then
+ kill -9 "$pid" 2>/dev/null || true
+ wait "$pid" 2>/dev/null || true
+ fi
+ rm -f target/app.pid
+ fi
+ for _ in $(seq 1 40); do
+ if ! (exec 3<>/dev/tcp/127.0.0.1/"${APP_PORT}") 2>/dev/null; then break; fi
+ sleep 0.25
+ done
+ exec 3<&- 2>/dev/null || true
+}
diff --git a/transactions/scripts/run-all.sh b/transactions/scripts/run-all.sh
new file mode 100755
index 0000000..3cb00e2
--- /dev/null
+++ b/transactions/scripts/run-all.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+# Regenerate every transcript under docs/output/.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+source scripts/env.sh
+
+"$MVN" -B -q package -DskipTests
+
+for demo in versions propagation rollback nested silent isolation; do
+ echo "=== $demo ==="
+ "scripts/demo-$demo.sh" > /dev/null
+done
+stop_app
+echo
+echo "regenerated:"
+ls -1 docs/output/
diff --git a/transactions/src/main/java/com/ankurm/tx/TransactionsApplication.java b/transactions/src/main/java/com/ankurm/tx/TransactionsApplication.java
new file mode 100644
index 0000000..680cec2
--- /dev/null
+++ b/transactions/src/main/java/com/ankurm/tx/TransactionsApplication.java
@@ -0,0 +1,21 @@
+package com.ankurm.tx;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * Companion application for the ankurm.com article
+ * "@Transactional in Spring: Propagation, Isolation, and the Six Ways It Silently Does Nothing".
+ *
+ * Every claim in that article is produced by running something here. The propagation
+ * matrix comes from {@code /tx/propagation}, which calls each of the seven propagation values
+ * from inside an outer transaction and reports what the transaction manager actually did; the
+ * failure gallery comes from {@code /tx/silent}, which runs six pieces of code that look
+ * transactional and are not.
+ */
+@SpringBootApplication
+public class TransactionsApplication {
+ public static void main(String[] args) {
+ SpringApplication.run(TransactionsApplication.class, args);
+ }
+}
diff --git a/transactions/src/main/java/com/ankurm/tx/config/NestedTransactionConfig.java b/transactions/src/main/java/com/ankurm/tx/config/NestedTransactionConfig.java
new file mode 100644
index 0000000..36e2015
--- /dev/null
+++ b/transactions/src/main/java/com/ankurm/tx/config/NestedTransactionConfig.java
@@ -0,0 +1,53 @@
+package com.ankurm.tx.config;
+
+import jakarta.persistence.EntityManagerFactory;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.orm.jpa.JpaTransactionManager;
+import org.springframework.orm.jpa.vendor.HibernateJpaDialect;
+import org.springframework.transaction.PlatformTransactionManager;
+
+/**
+ * Makes {@code Propagation.NESTED} work.
+ *
+ *
Out of the box it does not. {@link JpaTransactionManager} is created with
+ * {@code nestedTransactionAllowed} left at {@code false}, so the first {@code NESTED} call
+ * inside an existing transaction fails with:
+ *
+ *
+ * NestedTransactionNotSupportedException: Transaction manager does not allow nested
+ * transactions by default - specify 'nestedTransactionAllowed' property with value 'true'
+ *
+ *
+ * That is worth stating plainly, because {@code NESTED} is routinely described as "uses
+ * savepoints so the inner scope can roll back independently" without mentioning that a
+ * default Spring Boot JPA application cannot use it at all until this flag is flipped.
+ *
+ *
Flipping it is not free. Nested transactions are savepoints on one JDBC connection, so
+ * they require a resource-local transaction against a driver that supports savepoints. They
+ * do not work across a JTA transaction manager, and Hibernate's flush ordering means the
+ * savepoint only protects statements that have actually reached the database — a
+ * pending change still sitting in the persistence context is not covered by a rollback to
+ * savepoint until it is flushed.
+ *
+ *
Activated by {@code demo.nested-allowed=true}; {@code scripts/demo-nested.sh} runs the
+ * same scenarios with and without it.
+ */
+@Configuration
+@ConditionalOnProperty(name = "demo.nested-allowed", havingValue = "true")
+public class NestedTransactionConfig {
+
+ @Bean
+ public PlatformTransactionManager transactionManager(EntityManagerFactory factory) {
+ JpaTransactionManager manager = new JpaTransactionManager(factory);
+ // Constructing the manager by hand loses the JpaDialect Spring Boot would have
+ // supplied from the Hibernate vendor adapter, leaving the no-op DefaultJpaDialect.
+ // Miss this and NESTED fails with a DIFFERENT message -- "JpaDialect does not support
+ // savepoints" -- which sends you looking at your database instead of your @Bean.
+ manager.setJpaDialect(new HibernateJpaDialect());
+ manager.setNestedTransactionAllowed(true);
+ return manager;
+ }
+}
diff --git a/transactions/src/main/java/com/ankurm/tx/domain/Account.java b/transactions/src/main/java/com/ankurm/tx/domain/Account.java
new file mode 100644
index 0000000..cea110f
--- /dev/null
+++ b/transactions/src/main/java/com/ankurm/tx/domain/Account.java
@@ -0,0 +1,34 @@
+package com.ankurm.tx.domain;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+
+/** Minimal entity. The balance is what every rollback demonstration checks afterwards. */
+@Entity
+public class Account {
+
+ @Id
+ private String id;
+
+ private long balance;
+
+ protected Account() {
+ }
+
+ public Account(String id, long balance) {
+ this.id = id;
+ this.balance = balance;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public long getBalance() {
+ return balance;
+ }
+
+ public void setBalance(long balance) {
+ this.balance = balance;
+ }
+}
diff --git a/transactions/src/main/java/com/ankurm/tx/domain/AuditEntry.java b/transactions/src/main/java/com/ankurm/tx/domain/AuditEntry.java
new file mode 100644
index 0000000..30add67
--- /dev/null
+++ b/transactions/src/main/java/com/ankurm/tx/domain/AuditEntry.java
@@ -0,0 +1,34 @@
+package com.ankurm.tx.domain;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.Id;
+
+/**
+ * Written by the inner transaction in every propagation scenario. Whether a row survives the
+ * outer rollback is the whole question REQUIRES_NEW exists to answer.
+ */
+@Entity
+public class AuditEntry {
+
+ @Id
+ @GeneratedValue
+ private Long id;
+
+ private String note;
+
+ protected AuditEntry() {
+ }
+
+ public AuditEntry(String note) {
+ this.note = note;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public String getNote() {
+ return note;
+ }
+}
diff --git a/transactions/src/main/java/com/ankurm/tx/repo/AccountRepository.java b/transactions/src/main/java/com/ankurm/tx/repo/AccountRepository.java
new file mode 100644
index 0000000..163d771
--- /dev/null
+++ b/transactions/src/main/java/com/ankurm/tx/repo/AccountRepository.java
@@ -0,0 +1,8 @@
+package com.ankurm.tx.repo;
+
+import com.ankurm.tx.domain.Account;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface AccountRepository extends JpaRepository {
+}
diff --git a/transactions/src/main/java/com/ankurm/tx/repo/AuditRepository.java b/transactions/src/main/java/com/ankurm/tx/repo/AuditRepository.java
new file mode 100644
index 0000000..589bba2
--- /dev/null
+++ b/transactions/src/main/java/com/ankurm/tx/repo/AuditRepository.java
@@ -0,0 +1,8 @@
+package com.ankurm.tx.repo;
+
+import com.ankurm.tx.domain.AuditEntry;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface AuditRepository extends JpaRepository {
+}
diff --git a/transactions/src/main/java/com/ankurm/tx/service/InnerService.java b/transactions/src/main/java/com/ankurm/tx/service/InnerService.java
new file mode 100644
index 0000000..386d606
--- /dev/null
+++ b/transactions/src/main/java/com/ankurm/tx/service/InnerService.java
@@ -0,0 +1,93 @@
+package com.ankurm.tx.service;
+
+import java.util.Map;
+
+import com.ankurm.tx.domain.AuditEntry;
+import com.ankurm.tx.repo.AuditRepository;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * One method per propagation value, each writing an audit row and reporting the transaction
+ * state it found itself in.
+ *
+ * Called from {@link OuterService}, which decides whether an outer transaction exists. The
+ * combination of "outer transaction present or absent" and "propagation value" is the entire
+ * propagation table, and running it is more reliable than remembering it.
+ */
+@Service
+public class InnerService {
+
+ private final AuditRepository audit;
+
+ public InnerService(AuditRepository audit) {
+ this.audit = audit;
+ }
+
+ @Transactional(propagation = Propagation.REQUIRED)
+ public Map required(String note) {
+ return write("REQUIRED", note);
+ }
+
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public Map requiresNew(String note) {
+ return write("REQUIRES_NEW", note);
+ }
+
+ @Transactional(propagation = Propagation.NESTED)
+ public Map nested(String note) {
+ return write("NESTED", note);
+ }
+
+ @Transactional(propagation = Propagation.SUPPORTS)
+ public Map supports(String note) {
+ return write("SUPPORTS", note);
+ }
+
+ @Transactional(propagation = Propagation.NOT_SUPPORTED)
+ public Map notSupported(String note) {
+ return write("NOT_SUPPORTED", note);
+ }
+
+ @Transactional(propagation = Propagation.MANDATORY)
+ public Map mandatory(String note) {
+ return write("MANDATORY", note);
+ }
+
+ @Transactional(propagation = Propagation.NEVER)
+ public Map never(String note) {
+ return write("NEVER", note);
+ }
+
+ /** Marks the CURRENT transaction rollback-only and returns normally. */
+ @Transactional(propagation = Propagation.REQUIRED)
+ public void requiredThenFail(String note) {
+ write("REQUIRED (about to throw)", note);
+ throw new IllegalStateException("inner failed");
+ }
+
+ /** Independent transaction that fails: its own work rolls back, the caller's does not. */
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public void requiresNewThenFail(String note) {
+ write("REQUIRES_NEW (about to throw)", note);
+ throw new IllegalStateException("inner failed");
+ }
+
+ /** Rolls back to the savepoint only, if the transaction manager supports savepoints. */
+ @Transactional(propagation = Propagation.NESTED)
+ public void nestedThenFail(String note) {
+ write("NESTED (about to throw)", note);
+ throw new IllegalStateException("inner failed");
+ }
+
+ private Map write(String label, String note) {
+ Map state = TxProbe.snapshot("inner:" + label);
+ // SUPPORTS and NOT_SUPPORTED may have no transaction at all. Writing anyway is the
+ // point: the row is what proves whether the write was inside a transaction or not.
+ audit.save(new AuditEntry(note + ":" + label));
+ state.put("auditRowsVisibleFromHere", audit.count());
+ return state;
+ }
+}
diff --git a/transactions/src/main/java/com/ankurm/tx/service/JdbcNestedService.java b/transactions/src/main/java/com/ankurm/tx/service/JdbcNestedService.java
new file mode 100644
index 0000000..69f78d4
--- /dev/null
+++ b/transactions/src/main/java/com/ankurm/tx/service/JdbcNestedService.java
@@ -0,0 +1,89 @@
+package com.ankurm.tx.service;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import javax.sql.DataSource;
+
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.DataSourceTransactionManager;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.TransactionDefinition;
+import org.springframework.transaction.support.TransactionTemplate;
+
+/**
+ * {@code Propagation.NESTED} actually working — which requires leaving JPA behind.
+ *
+ * {@link org.springframework.orm.jpa.JpaTransactionManager} cannot do nested transactions.
+ * Setting {@code nestedTransactionAllowed=true} gets you past the first check and into a
+ * second one, {@code "JpaDialect does not support savepoints"}, which no amount of
+ * configuration clears: the savepoint manager comes from the object the dialect returns when
+ * it begins the transaction, and Hibernate's does not implement one.
+ *
+ *
{@link DataSourceTransactionManager} does, because a savepoint is a JDBC concept and it
+ * is holding the JDBC connection directly. This service uses its own transaction manager over
+ * the same {@link DataSource} so the article can show the mechanism succeeding rather than
+ * only failing.
+ *
+ *
Mixing two transaction managers over one DataSource in a real application is a way to
+ * lose an afternoon; this is a demonstration, not a recommendation. The honest advice, which
+ * the article gives, is that {@code REQUIRES_NEW} solves most of what people reach for
+ * {@code NESTED} to solve.
+ */
+@Service
+public class JdbcNestedService {
+
+ private final JdbcTemplate jdbc;
+ private final TransactionTemplate outerTx;
+ private final TransactionTemplate nestedTx;
+
+ public JdbcNestedService(DataSource dataSource) {
+ this.jdbc = new JdbcTemplate(dataSource);
+
+ DataSourceTransactionManager manager = new DataSourceTransactionManager(dataSource);
+ manager.setNestedTransactionAllowed(true);
+
+ this.outerTx = new TransactionTemplate(manager);
+ this.nestedTx = new TransactionTemplate(manager);
+ this.nestedTx.setPropagationBehavior(TransactionDefinition.PROPAGATION_NESTED);
+ }
+
+ /**
+ * Writes one row in the outer transaction and one in a nested scope, rolls the nested
+ * scope back, and commits the outer one. The savepoint means the first row survives and
+ * the second does not — the partial rollback NESTED exists for.
+ */
+ public Map partialRollback() {
+ jdbc.execute("CREATE TABLE IF NOT EXISTS nested_demo (note VARCHAR(64))");
+ jdbc.update("DELETE FROM nested_demo");
+
+ Map result = new LinkedHashMap<>();
+
+ outerTx.executeWithoutResult(outerStatus -> {
+ jdbc.update("INSERT INTO nested_demo VALUES ('outer-row')");
+
+ try {
+ nestedTx.executeWithoutResult(nestedStatus -> {
+ jdbc.update("INSERT INTO nested_demo VALUES ('nested-row')");
+ result.put("rowsVisibleInsideNestedScope",
+ jdbc.queryForObject("SELECT COUNT(*) FROM nested_demo", Integer.class));
+ throw new IllegalStateException("nested scope fails");
+ });
+ } catch (IllegalStateException ex) {
+ // Caught OUTSIDE the nested scope. With NESTED this is survivable: the
+ // rollback went to the savepoint, not to the start of the outer transaction.
+ result.put("nestedScopeThrew", ex.getMessage());
+ }
+
+ result.put("rowsAfterNestedRollback",
+ jdbc.queryForObject("SELECT COUNT(*) FROM nested_demo", Integer.class));
+ });
+
+ result.put("rowsAfterOuterCommit",
+ jdbc.queryForObject("SELECT COUNT(*) FROM nested_demo", Integer.class));
+ result.put("surviving",
+ jdbc.queryForList("SELECT note FROM nested_demo", String.class));
+ result.put("transactionManager", "DataSourceTransactionManager (not JpaTransactionManager)");
+ return result;
+ }
+}
diff --git a/transactions/src/main/java/com/ankurm/tx/service/NotABean.java b/transactions/src/main/java/com/ankurm/tx/service/NotABean.java
new file mode 100644
index 0000000..ee07b7e
--- /dev/null
+++ b/transactions/src/main/java/com/ankurm/tx/service/NotABean.java
@@ -0,0 +1,18 @@
+package com.ankurm.tx.service;
+
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * 6. The object is not a bean.
+ *
+ * Constructed with {@code new} in a helper, a factory or a test. Spring never saw it, so
+ * there is no proxy and {@code @Transactional} is documentation. This is the failure mode that
+ * survives code review most easily, because the annotation is right there on the method.
+ */
+public class NotABean {
+
+ @Transactional
+ public String work() {
+ return "created with new: actualTransactionActive=" + TxProbe.active();
+ }
+}
diff --git a/transactions/src/main/java/com/ankurm/tx/service/OuterService.java b/transactions/src/main/java/com/ankurm/tx/service/OuterService.java
new file mode 100644
index 0000000..b1085f3
--- /dev/null
+++ b/transactions/src/main/java/com/ankurm/tx/service/OuterService.java
@@ -0,0 +1,103 @@
+package com.ankurm.tx.service;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+import com.ankurm.tx.repo.AuditRepository;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * Runs a piece of inner work either inside an outer transaction or outside one, so each
+ * propagation value can be observed in both situations.
+ *
+ *
The {@code *AndRollback} variants throw after the inner call, which is how the article
+ * answers the question people actually have: does the inner work survive when the outer
+ * transaction fails?
+ */
+@Service
+public class OuterService {
+
+ private final AuditRepository audit;
+
+ public OuterService(AuditRepository audit) {
+ this.audit = audit;
+ }
+
+ /** Calls the inner work with an outer physical transaction in progress. */
+ @Transactional
+ public Map inTransaction(Function> inner) {
+ Map result = new LinkedHashMap<>();
+ result.put("outer", TxProbe.snapshot("outer (REQUIRED)"));
+ result.put("inner", inner.apply("in-tx"));
+ return result;
+ }
+
+ /** Calls the same inner work with no transaction in progress. */
+ public Map withoutTransaction(Function> inner) {
+ Map result = new LinkedHashMap<>();
+ result.put("outer", TxProbe.snapshot("outer (no @Transactional)"));
+ result.put("inner", inner.apply("no-tx"));
+ return result;
+ }
+
+ /**
+ * Calls the inner work, then throws. Whatever the inner call committed independently
+ * survives; whatever joined the outer transaction does not.
+ */
+ @Transactional
+ public void inTransactionThenFail(Consumer inner) {
+ inner.accept("outer-fails");
+ throw new IllegalStateException("outer failed after the inner call returned");
+ }
+
+ /**
+ * Calls an inner method that throws, catches the exception, and returns normally.
+ *
+ * With {@code REQUIRED} the inner scope has already marked the shared transaction
+ * rollback-only by the time the exception is caught, so catching it does not save the
+ * transaction — the commit at the end of this method fails with
+ * {@code UnexpectedRollbackException}. This surprises people every time.
+ */
+ @Transactional
+ public String catchInnerFailure(Consumer inner) {
+ try {
+ inner.accept("caught");
+ } catch (RuntimeException ex) {
+ return "caught " + ex.getClass().getSimpleName() + ", returning normally";
+ }
+ return "inner did not throw";
+ }
+
+ /** Read-only scope, used to show what read-only does and does not prevent. */
+ @Transactional(readOnly = true)
+ public Map readOnlyScope() {
+ Map state = TxProbe.snapshot("outer (readOnly = true)");
+ state.put("auditRows", audit.count());
+ return state;
+ }
+
+ /** Declares an isolation level, which is honoured only when it starts a transaction. */
+ @Transactional(isolation = org.springframework.transaction.annotation.Isolation.SERIALIZABLE)
+ public Map serializableScope(Function> inner) {
+ Map result = new LinkedHashMap<>();
+ result.put("outer", TxProbe.snapshot("outer (SERIALIZABLE)"));
+ result.put("inner", inner.apply("serializable"));
+ return result;
+ }
+
+ /**
+ * An inner scope that declares its own isolation level while joining an existing
+ * transaction. The declaration is silently ignored, because there is only one physical
+ * transaction and its isolation was fixed when it began.
+ */
+ @Transactional(propagation = Propagation.REQUIRED,
+ isolation = org.springframework.transaction.annotation.Isolation.READ_UNCOMMITTED)
+ public Map readUncommittedParticipant() {
+ return TxProbe.snapshot("inner (REQUIRED + READ_UNCOMMITTED declared)");
+ }
+}
diff --git a/transactions/src/main/java/com/ankurm/tx/service/SilentlyNonTransactional.java b/transactions/src/main/java/com/ankurm/tx/service/SilentlyNonTransactional.java
new file mode 100644
index 0000000..283a67c
--- /dev/null
+++ b/transactions/src/main/java/com/ankurm/tx/service/SilentlyNonTransactional.java
@@ -0,0 +1,114 @@
+package com.ankurm.tx.service;
+
+import com.ankurm.tx.domain.Account;
+import com.ankurm.tx.repo.AccountRepository;
+
+import jakarta.annotation.PostConstruct;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * Six pieces of code carrying {@code @Transactional} that are not transactional.
+ *
+ * None of them warn. None of them fail at startup. Each one runs, appears to work, and
+ * leaves the database in a state nobody asked for. They are numbered to match the article's
+ * gallery, and {@code /tx/silent} runs all six and reports
+ * {@code actualTransactionActive} for each.
+ */
+@Service
+public class SilentlyNonTransactional {
+
+ private final AccountRepository accounts;
+
+ /** Recorded during {@link #onStartup()} so the article can show what it saw. */
+ private boolean transactionActiveDuringPostConstruct;
+
+ public SilentlyNonTransactional(AccountRepository accounts) {
+ this.accounts = accounts;
+ }
+
+ /**
+ * 1. Self-invocation. {@link #entryPoint()} is called through the proxy,
+ * so the interceptor runs for it — but it is not annotated. The call it makes to
+ * {@link #annotatedButCalledInternally()} is a plain {@code this.} call, so the
+ * interceptor never sees it and no transaction is started.
+ */
+ public String entryPoint() {
+ return annotatedButCalledInternally();
+ }
+
+ @Transactional
+ public String annotatedButCalledInternally() {
+ return "self-invocation: actualTransactionActive=" + TxProbe.active();
+ }
+
+ /**
+ * 2. A private method. A CGLIB proxy advises by overriding, and a private
+ * method cannot be overridden. The annotation is legal Java and has no effect. IntelliJ
+ * warns about this one; the compiler does not.
+ */
+ public String callsPrivate() {
+ return privateTransactional();
+ }
+
+ @Transactional
+ private String privateTransactional() {
+ return "private method: actualTransactionActive=" + TxProbe.active();
+ }
+
+ /**
+ * 3. A checked exception. The default rollback rule is
+ * {@code RuntimeException} or {@code Error}. A checked exception propagates out of the
+ * method and the transaction commits on the way, which is the opposite of what
+ * almost everyone expects the first time.
+ *
+ *
Fix: {@code @Transactional(rollbackFor = Exception.class)}.
+ */
+ @Transactional
+ public void checkedExceptionCommits(String id) throws Exception {
+ accounts.save(new Account(id, 999));
+ throw new Exception("checked -- this does NOT trigger rollback");
+ }
+
+ /**
+ * 4. Swallowing the exception. Catching it inside the transactional
+ * method means nothing propagates, so the interceptor sees a normal return and commits.
+ * The write survives a failure the code appeared to handle.
+ */
+ @Transactional
+ public void swallowsException(String id) {
+ accounts.save(new Account(id, 555));
+ try {
+ throw new IllegalStateException("something went wrong");
+ } catch (RuntimeException ex) {
+ // Deliberately swallowed. The commit still happens.
+ }
+ }
+
+ /**
+ * 5. Called from {@code @PostConstruct}. The proxy is not in place while
+ * the bean is still being initialised, so the annotation on the method being called has
+ * nothing to intercept it. The reference documentation says not to rely on it here; this
+ * records what actually happens.
+ */
+ @PostConstruct
+ void onStartup() {
+ this.transactionActiveDuringPostConstruct = duringInitialisation();
+ }
+
+ @Transactional
+ public boolean duringInitialisation() {
+ return TxProbe.active();
+ }
+
+ public boolean wasTransactionActiveDuringPostConstruct() {
+ return transactionActiveDuringPostConstruct;
+ }
+
+ /** Used by the endpoint to prove the same method IS transactional through the proxy. */
+ @Transactional
+ public String properlyCalled() {
+ return "through the proxy: actualTransactionActive=" + TxProbe.active();
+ }
+}
diff --git a/transactions/src/main/java/com/ankurm/tx/service/TxProbe.java b/transactions/src/main/java/com/ankurm/tx/service/TxProbe.java
new file mode 100644
index 0000000..663e781
--- /dev/null
+++ b/transactions/src/main/java/com/ankurm/tx/service/TxProbe.java
@@ -0,0 +1,61 @@
+package com.ankurm.tx.service;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
+/**
+ * Reports what the transaction infrastructure believes is happening at the point it is called.
+ *
+ *
This is the tool that turns "@Transactional isn't working" from a guess into a
+ * measurement. {@link TransactionSynchronizationManager} is public API and every field below
+ * is available anywhere in application code, which is worth knowing before spending an
+ * afternoon adding log statements.
+ *
+ *
The distinction that matters most is {@code actualTransactionActive}: a method can be
+ * inside a {@code @Transactional} scope and still have no physical transaction, which is
+ * exactly what {@code NOT_SUPPORTED} and a missing proxy both look like.
+ */
+public final class TxProbe {
+
+ private TxProbe() {
+ }
+
+ public static Map snapshot(String where) {
+ Map state = new LinkedHashMap<>();
+ state.put("where", where);
+ state.put("actualTransactionActive",
+ TransactionSynchronizationManager.isActualTransactionActive());
+ state.put("transactionName",
+ TransactionSynchronizationManager.getCurrentTransactionName());
+ state.put("readOnly",
+ TransactionSynchronizationManager.isCurrentTransactionReadOnly());
+ Integer isolation = TransactionSynchronizationManager.getCurrentTransactionIsolationLevel();
+ state.put("isolationLevel", isolation == null ? "default (from the connection)"
+ : isolationName(isolation));
+ state.put("synchronizationActive",
+ TransactionSynchronizationManager.isSynchronizationActive());
+ return state;
+ }
+
+ /** True when a physical transaction is in progress. The one-line answer. */
+ public static boolean active() {
+ return TransactionSynchronizationManager.isActualTransactionActive();
+ }
+
+ public static String name() {
+ String name = TransactionSynchronizationManager.getCurrentTransactionName();
+ return name == null ? "(none)" : name.substring(name.lastIndexOf('.') + 1);
+ }
+
+ private static String isolationName(int level) {
+ return switch (level) {
+ case 1 -> "READ_UNCOMMITTED";
+ case 2 -> "READ_COMMITTED";
+ case 4 -> "REPEATABLE_READ";
+ case 8 -> "SERIALIZABLE";
+ default -> "level " + level;
+ };
+ }
+}
diff --git a/transactions/src/main/java/com/ankurm/tx/web/TransactionEndpoint.java b/transactions/src/main/java/com/ankurm/tx/web/TransactionEndpoint.java
new file mode 100644
index 0000000..2425150
--- /dev/null
+++ b/transactions/src/main/java/com/ankurm/tx/web/TransactionEndpoint.java
@@ -0,0 +1,171 @@
+package com.ankurm.tx.web;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import com.ankurm.tx.repo.AccountRepository;
+import com.ankurm.tx.repo.AuditRepository;
+import com.ankurm.tx.service.InnerService;
+import com.ankurm.tx.service.JdbcNestedService;
+import com.ankurm.tx.service.NotABean;
+import com.ankurm.tx.service.OuterService;
+import com.ankurm.tx.service.SilentlyNonTransactional;
+
+import org.springframework.transaction.UnexpectedRollbackException;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/** Drives every scenario the article quotes. */
+@RestController
+public class TransactionEndpoint {
+
+ private final OuterService outer;
+ private final InnerService inner;
+ private final SilentlyNonTransactional silent;
+ private final JdbcNestedService jdbcNested;
+ private final AccountRepository accounts;
+ private final AuditRepository audit;
+
+ public TransactionEndpoint(OuterService outer, InnerService inner,
+ SilentlyNonTransactional silent, JdbcNestedService jdbcNested,
+ AccountRepository accounts, AuditRepository audit) {
+ this.outer = outer;
+ this.inner = inner;
+ this.silent = silent;
+ this.jdbcNested = jdbcNested;
+ this.accounts = accounts;
+ this.audit = audit;
+ }
+
+ /** Each propagation value, called both inside and outside an outer transaction. */
+ @GetMapping("/tx/propagation")
+ public Map propagation() {
+ audit.deleteAll();
+ Map result = new LinkedHashMap<>();
+
+ record Case(String name, java.util.function.Function> call) {
+ }
+ var cases = java.util.List.of(
+ new Case("REQUIRED", inner::required),
+ new Case("REQUIRES_NEW", inner::requiresNew),
+ new Case("NESTED", inner::nested),
+ new Case("SUPPORTS", inner::supports),
+ new Case("NOT_SUPPORTED", inner::notSupported),
+ new Case("MANDATORY", inner::mandatory),
+ new Case("NEVER", inner::never));
+
+ for (Case c : cases) {
+ Map row = new LinkedHashMap<>();
+ row.put("withOuterTransaction", attempt(() -> outer.inTransaction(c.call())));
+ row.put("withoutOuterTransaction", attempt(() -> outer.withoutTransaction(c.call())));
+ result.put(c.name(), row);
+ }
+ return result;
+ }
+
+ /** Does the inner write survive when the outer transaction rolls back? */
+ @GetMapping("/tx/rollback")
+ public Map rollback() {
+ Map result = new LinkedHashMap<>();
+
+ result.put("REQUIRED inner, outer rolls back",
+ survives(() -> outer.inTransactionThenFail(note -> inner.required(note))));
+ result.put("REQUIRES_NEW inner, outer rolls back",
+ survives(() -> outer.inTransactionThenFail(note -> inner.requiresNew(note))));
+ result.put("NESTED inner, outer rolls back",
+ survives(() -> outer.inTransactionThenFail(note -> inner.nested(note))));
+
+ result.put("REQUIRED inner throws, outer catches it",
+ survives(() -> outer.catchInnerFailure(inner::requiredThenFail)));
+ result.put("REQUIRES_NEW inner throws, outer catches it",
+ survives(() -> outer.catchInnerFailure(inner::requiresNewThenFail)));
+ result.put("NESTED inner throws, outer catches it",
+ survives(() -> outer.catchInnerFailure(inner::nestedThenFail)));
+ return result;
+ }
+
+ /** The six ways it silently does nothing. */
+ @GetMapping("/tx/silent")
+ public Map silent() {
+ Map result = new LinkedHashMap<>();
+
+ result.put("0-control-through-the-proxy", silent.properlyCalled());
+ result.put("1-self-invocation", silent.entryPoint());
+ result.put("2-private-method", silent.callsPrivate());
+
+ Map checked = new LinkedHashMap<>();
+ accounts.deleteAll();
+ try {
+ silent.checkedExceptionCommits("checked-1");
+ } catch (Exception ex) {
+ checked.put("threw", ex.getClass().getSimpleName());
+ }
+ checked.put("rowSurvived", accounts.existsById("checked-1"));
+ checked.put("verdict", accounts.existsById("checked-1")
+ ? "COMMITTED despite the exception" : "rolled back");
+ result.put("3-checked-exception", checked);
+
+ Map swallowed = new LinkedHashMap<>();
+ silent.swallowsException("swallowed-1");
+ swallowed.put("rowSurvived", accounts.existsById("swallowed-1"));
+ swallowed.put("verdict", accounts.existsById("swallowed-1")
+ ? "COMMITTED -- the exception never reached the interceptor" : "rolled back");
+ result.put("4-swallowed-exception", swallowed);
+
+ result.put("5-called-from-post-construct", Map.of(
+ "transactionActiveDuringPostConstruct",
+ silent.wasTransactionActiveDuringPostConstruct()));
+
+ result.put("6-created-with-new", new NotABean().work());
+ return result;
+ }
+
+ /**
+ * NESTED working, on a JDBC transaction manager, because it cannot work on a JPA one.
+ */
+ @GetMapping("/tx/nested-jdbc")
+ public Map nestedJdbc() {
+ return jdbcNested.partialRollback();
+ }
+
+ /** Isolation and read-only: declared where it counts, and declared where it is ignored. */
+ @GetMapping("/tx/isolation")
+ public Map isolation() {
+ Map result = new LinkedHashMap<>();
+ result.put("readOnlyScope", attempt(outer::readOnlyScope));
+ result.put("serializableOuter",
+ attempt(() -> outer.serializableScope(inner::required)));
+ result.put("participantDeclaringReadUncommitted",
+ attempt(() -> outer.inTransaction(note -> outer.readUncommittedParticipant())));
+ return result;
+ }
+
+ private Object attempt(java.util.function.Supplier