Spring · 08

Spring Transactions

@Transactional is the most used and least understood annotation in Spring. It is not magic: it is a proxy that opens a transaction, binds it to the current thread, runs your method and commits or rolls back by a precise set of rules. This page takes it apart: ACID, the interceptor, all seven propagation levels, rollback rules (checked vs unchecked), isolation levels with their anomalies, the self-invocation trap, transactions with events and async, and what to do when one database transaction is not enough. It finishes with a transaction manager and proxy you can compile and run.

13
Chapters
7
Propagations
4
Isolation levels
2
Runnable demos
01
Chapter One · Why It Exists

All-or-Nothing Without the Boilerplate

one transaction debit A -100 credit B +100 COMMIT (both) or ROLLBACK (neither)

Moving 100 from account A to account B is two UPDATE statements. If the process dies, the network drops or a constraint fails between them, the money has vanished. A transaction groups statements into one unit that either fully happens or does not happen at all.

Before — manual JDBC transaction handling
public void transfer(long from, long to, BigDecimal amount) throws SQLException { Connection con = dataSource.getConnection(); try { con.setAutoCommit(false); debit(con, from, amount); // every helper must receive the SAME connection credit(con, to, amount); con.commit(); } catch (Exception e) { con.rollback(); throw e; } finally { con.setAutoCommit(true); con.close(); } }
After — declarative
@Service public class TransferService { private final AccountRepository accounts; public TransferService(AccountRepository accounts) { this.accounts = accounts; } @Transactional public void transfer(long from, long to, BigDecimal amount) { Account a = accounts.findById(from).orElseThrow(); Account b = accounts.findById(to).orElseThrow(); a.withdraw(amount); // throws InsufficientFundsException (unchecked) -> rollback b.deposit(amount); } // returns normally -> flush + COMMIT }

Everything the manual version did (acquire a connection, disable auto-commit, share it with every helper, commit, roll back, restore, release) is done by the framework. The price: you must know the rules the framework applies, because the failure modes are silent.

Analogy: A transaction is a shopping basket with a single checkout. Items (statements) go into the basket; nothing is bought until checkout (commit). If anything goes wrong you abandon the basket (rollback) and the shelves are exactly as they were. Isolation decides whether other shoppers can see what is in your basket.
02
Chapter Two · Mental Model

ACID, Thread Binding and the Proxy

Atomicity
▶
All statements commit or none do. Implemented by the database (undo log / WAL); Spring only decides when to ask for commit or rollback.
Consistency
▶
Constraints and invariants hold before and after. The database enforces keys and checks; your code must enforce business invariants inside the transaction.
Isolation
▶
Concurrent transactions do not see each other's half-done work, to a degree chosen by the isolation level (Chapter 06). It is a trade between correctness and concurrency.
Durability
▶
Once commit returns, the data survives a crash (the log is flushed to disk).
Spring's job
▶
Open the transaction on a connection, bind it to the current thread, run the method, then commit or roll back according to the outcome.
Thread binding is the key
▶
Every repository call on that thread finds the same EntityManager/Connection through TransactionSynchronizationManager (ThreadLocals). A different thread sees nothing.
One @Transactional call: who calls whom (sequence)
Caller CGLIB proxy TxInterceptor TxManager (JPA) Your bean transfer(1,2,100) invoke(advice chain) getTransaction(attr) new EntityManager, begin, bind to thread proceed(): real method repo calls reuse bound EM return / throw commit(status) or rollback(status) flush, COMMIT, unbind, close rethrow / return value result
The one-sentence model
  • @Transactional = an around advice (AOP) on a Spring-created proxy that does: begin → try proceed → commit / rollback by exception type → always release.
  • If the call does not go through the proxy (self-invocation, new, wrong thread), no transaction exists and nothing warns you.
03
Chapter Three · Mechanics

Inside @Transactional: Attribute Source, Interceptor, Manager

Three collaborators do the work: an attribute source that reads your annotation, an interceptor that wraps the method, and a transaction manager that talks to the resource. With Spring Boot you never configure them, but you must know which one is active.

StepClassWhat happens
1@EnableTransactionManagement (Boot: TransactionAutoConfiguration)Registers BeanFactoryTransactionAttributeSourceAdvisor; Boot sets proxyTargetClass=true (CGLIB) by default
2Bean post-processor (InfrastructureAdvisorAutoProxyCreator)For each bean with @Transactional on class or methods, wraps it in a proxy
3AnnotationTransactionAttributeSourceResolves the attributes for the invoked method: method annotation, then class annotation (most specific wins)
4TransactionInterceptor.invokeWithinTransactionPicks the PlatformTransactionManager, calls getTransaction, then proceed()
5JpaTransactionManager.doBeginCreates an EntityManager, begins the resource transaction, applies isolation/timeout/readOnly, binds the resources to the thread
6on success commitTransactionAfterReturningcommit: JPA flush, JDBC commit, unbind, close
7on exception completeTransactionAfterThrowingAsks rollbackOn(ex): rollback for RuntimeException/Error, commit for checked exceptions by default; the exception is rethrown either way
AttributeDefaultMeaning
propagationREQUIREDJoin an existing transaction or create one (Chapter 04)
isolationDEFAULTUse the database default; only applied when a new transaction starts
readOnlyfalseOptimisation hint: Hibernate flush mode MANUAL, Connection.setReadOnly(true)
timeout-1 (none)Seconds before the transaction is marked rollback-only/time-out
rollbackFor / rollbackForClassNameRuntimeException, ErrorExtra exception types that force rollback
noRollbackFornoneUnchecked exceptions that should still commit
transactionManager / labelthe single managerSelect a manager by bean name when there are several datasources
Where it can be placed and how attributes combine
@Service @Transactional(readOnly = true) // class level default: every public method is a read-only tx public class CatalogService { public Product find(long id) { ... } // inherits readOnly = true @Transactional // method level overrides class level (readOnly back to false) public Product rename(long id, String name) { ... } @Transactional(propagation = Propagation.REQUIRES_NEW, timeout = 5) public void audit(String event) { ... } }
See it work: turn on transaction logging (application.properties)
logging.level.org.springframework.transaction.interceptor=TRACE logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG # expected lines: # Creating new transaction with name [com.acme.TransferService.transfer]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT # Participating in existing transaction # Initiating transaction commit # Initiating transaction rollback # Participating transaction failed - marking existing transaction as rollback-only
ManagerWhen Boot picks itNotes
JpaTransactionManagerspring-data-jpa / hibernate on classpathAlso exposes the JDBC connection, so JdbcTemplate in the same transaction shares it
DataSourceTransactionManagerJDBC only (spring-boot-starter-jdbc)Connection bound via DataSourceUtils
JtaTransactionManagerYou configure itFor XA / multiple resources in one transaction
⚠ Common bugs & edge cases:
  • Annotation on an interface method. Works only with interface-based (JDK) proxies; since Boot uses CGLIB by default, put @Transactional on the class.
  • Non-public methods. Before Spring 6.0 they were silently ignored; since 6.0, protected and package-visible methods work with class-based proxies, but private never does and final methods/classes cannot be overridden by CGLIB.
  • Wrong annotation import. jakarta.transaction.Transactional is also supported but has different defaults (rolls back checked exceptions only if listed, no readOnly); prefer org.springframework.transaction.annotation.Transactional.
  • Two datasources, one default manager. The transaction silently runs against the primary datasource: queries on the second one are not transactional. Name the manager with @Transactional("secondTx").
  • Transaction placed on the controller. The transaction then spans JSON serialization and response writing, holding a connection for as long as the slowest client. Put it on the service layer, one use case per transaction.
  • How to check at runtime: TransactionSynchronizationManager.isActualTransactionActive() and getCurrentTransactionName() tell you whether you are really inside one.
04
Chapter Four · Propagation

All Seven Levels, With the Failure Modes

Propagation answers one question: when my transactional method is called, and a transaction may or may not already exist on this thread, what should happen? It is evaluated by AbstractPlatformTransactionManager.getTransaction on every call through the proxy.

Four of the seven at a glance
REQUIRED (default): join outer() — tx1 inner() — joins tx1 one physical tx; inner failure marks tx1 rollback-only REQUIRES_NEW: suspend + new outer tx1 tx1 resumes inner tx2 (own commit) two physical txs, two connections; tx1 suspended NESTED: savepoint outer() — tx1 inner: SAVEPOINT sp1 inner fails: ROLLBACK TO sp1, outer continues NOT_SUPPORTED / NEVER / SUPPORTS / MANDATORY outer tx1 NOT_SUPPORTED: no tx tx1 suspended; method runs non-transactionally
PropagationTx already existsNo tx existsTypical use
REQUIREDjoin itcreate newDefault for service methods
REQUIRES_NEWsuspend it, create new, resume aftercreate newAudit log / outbox that must survive caller rollback; independent commit
NESTEDsavepoint inside itcreate new (like REQUIRED)Optional step whose failure should not abort the whole unit; needs savepoint-capable manager (JDBC / JPA with JDBC)
SUPPORTSjoin itrun without txRead helpers that work both ways
NOT_SUPPORTEDsuspend it, run withoutrun withoutCall slow or non-transactional resources without holding the tx
MANDATORYjoin itthrow IllegalTransactionStateExceptionMethods that must never be called outside a transaction
NEVERthrow IllegalTransactionStateExceptionrun withoutGuards: must not run inside a tx
REQUIRES_NEW for an audit record that must be saved even if the business tx rolls back
@Service public class OrderService { private final OrderRepository orders; private final AuditService audit; // a SEPARATE bean: needed so the call crosses the proxy public OrderService(OrderRepository orders, AuditService audit) { this.orders = orders; this.audit = audit; } @Transactional public void place(OrderRequest req) { audit.record("ORDER_ATTEMPT", req.customerId()); // committed independently, immediately orders.save(Order.from(req)); if (req.total().signum() <= 0) throw new IllegalArgumentException("total must be positive"); // rolls back the order only } } @Service public class AuditService { private final AuditRepository repo; public AuditService(AuditRepository repo) { this.repo = repo; } @Transactional(propagation = Propagation.REQUIRES_NEW) public void record(String event, long customerId) { repo.save(new AuditEntry(event, customerId)); } }

The runnable model in Chapter 11 produces exactly this behaviour. Scenarios A and B (audit succeeds / audit fails and is caught):

Output from MiniTx (scenarios A and B)
A. REQUIRED + REQUIRES_NEW, audit succeeds [tx1] BEGIN placeOrder in tx1 (suspending tx1) [tx2] BEGIN audit.write in tx2 [tx2] COMMIT (resuming tx1) [tx1] COMMIT B. audit (REQUIRES_NEW) fails and is caught: outer still commits [tx3] BEGIN placeOrder in tx3 (suspending tx3) [tx4] BEGIN audit.write in tx4 [tx4] ROLLBACK (resuming tx3) placeOrder caught: audit down [tx3] COMMIT
REQUIRES_NEW needs a second connection
▶
The outer transaction keeps its connection while the inner one borrows another from the pool. With pool size 10 and 10 threads each calling REQUIRES_NEW, every thread waits for a connection nobody can release: pool deadlock.
The inner commit is permanent
▶
If the outer later rolls back, the inner changes stay. Use it only for data that is genuinely independent (audit, idempotency keys), never for "part of the same business operation".
Inner cannot see uncommitted outer data
▶
It runs on a different transaction, so under READ COMMITTED it does not see rows the outer just inserted; a foreign key to them fails or blocks on a lock.
NESTED: try an optional step, keep the rest
@Transactional public void importBatch(List<Row> rows) { for (Row r : rows) { try { rowImporter.importOne(r); // @Transactional(propagation = NESTED) on another bean } catch (DataIntegrityViolationException e) { rejected.add(r); // savepoint rolled back, outer tx still healthy } } }
⚠ Common bugs & edge cases:
  • REQUIRES_NEW on a method called via this.: no proxy, no new transaction. The audit joins (or does not even have) the caller transaction. Use a separate bean (Chapter 08).
  • Inner REQUIRED throws, outer catches and carries on. The shared transaction is already marked rollback-only; the outer method returns normally and the proxy throws UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only at commit. If you want to continue after a failure, the inner must be NESTED or REQUIRES_NEW. (Scenario D in the demo.)
  • NESTED with JPA on a manager without savepoint support: NestedTransactionNotSupportedException. JpaTransactionManager over a JDBC datasource supports it; also remember Hibernate state is not rolled back to the savepoint: entities changed inside the nested part stay changed in memory.
  • MANDATORY / NEVER misuse shows up as IllegalTransactionStateException: No existing transaction found for transaction marked with propagation 'mandatory'. That is the guard doing its job: find the caller that forgot the transaction.
  • Stuck at pool exhaustion: HikariPool-1 - Connection is not available, request timed out after 30000ms with REQUIRES_NEW in the stack: size the pool at least threads x (nesting depth) or avoid nested new transactions.
05
Chapter Five · Rollback Rules

Checked vs Unchecked, Rollback-Only and Commit-Time Failures

By default Spring rolls back only on unchecked exceptions (RuntimeException and Error). A checked exception commits. This mirrors EJB semantics ("checked exceptions are business outcomes, unchecked are system failures") and surprises almost everyone.

Thrown out of the methodDefault outcomeChange it with
RuntimeException (incl. DataAccessException, IllegalStateException)ROLLBACKnoRollbackFor = X.class
ErrorROLLBACK—
Checked, e.g. IOException, custom extends ExceptionCOMMITrollbackFor = Exception.class
Nothing thrown, but setRollbackOnly() calledROLLBACK—
Nothing thrown, but a participating inner call failedROLLBACK + UnexpectedRollbackExceptionUse NESTED / REQUIRES_NEW for recoverable inner steps
Rules in code
@Transactional(rollbackFor = Exception.class) // team-wide safe default for services public void importFile(Path p) throws IOException { ... } // checked IOException now rolls back too @Transactional(noRollbackFor = DuplicateEventException.class) public void handle(Event e) { ... } // unchecked, but we WANT to commit what was done so far @Transactional public void conditional(Order o) { if (o.isSuspicious()) { TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); // roll back without throwing return; } orders.save(o); }
The rollback-only trap: catching the exception does not un-fail the transaction
outerService.process() @Transactional → tx1 (owner) innerService.step() @Transactional → joins tx1 throws IllegalArgumentException inner proxy: rollbackOn → tx1.setRollbackOnly = TRUE outer: try { inner.step() } catch (...) { log } outer returns normally → proxy calls commit() commit(): flag is set → ROLLBACK + throw UnexpectedRollbackException

Scenarios C (checked exception commits) and D (rollback-only trap) from the runnable model:

Output from MiniTx (scenarios C, D, E)
C. checked exception: default rule COMMITS [tx5] BEGIN [tx5] COMMIT caller saw: checked D. inner REQUIRED fails, outer swallows: rollback-only trap [tx6] BEGIN (joining tx6) (participant failed: tx6 marked rollback-only) outer swallowed: inner boom [tx6] ROLLBACK caller saw: Transaction silently rolled back because it has been marked as rollback-only E. self-invocation: this.audit() never sees a transaction audit sees tx: none tx active? false
When the exception is thrown at commit time
Constraint violations, optimistic-lock conflicts and deferred checks surface during the flush at commit, after your method body finished.
This catch block never runs
@Transactional public void register(User u) { try { users.save(u); // IDENTITY: INSERT runs now; SEQUENCE: INSERT is deferred to flush } catch (DataIntegrityViolationException e) { throw new EmailTakenException(u.email()); // only reached for IDENTITY ids! } } // with SEQUENCE the unique violation is thrown HERE, by the proxy's commit // fix: flush inside the try, or translate in the caller of the proxy, or check existence first users.saveAndFlush(u);
⚠ Common bugs & edge cases:
  • Checked exception, data still saved. The classic "the transfer failed with InsufficientFundsException extends Exception but the debit was stored". Declare rollbackFor, or make the exception unchecked.
  • Swallowed exception in the same transaction. After a DataAccessException on PostgreSQL the whole transaction is aborted anyway: current transaction is aborted, commands ignored until end of transaction block. You cannot continue using that transaction.
  • rollbackFor on the wrong layer. Rollback is decided by the proxy that owns or joins the transaction; the exception must pass out of a transactional method boundary to be seen.
  • @Transactional method that catches Exception and returns an error DTO. The proxy sees a normal return and commits partial work. Rethrow, or call setRollbackOnly().
  • Wrapping exceptions in a checked one (throw new Exception(e)) turns a rollback into a commit. Keep the unchecked type.
06
Chapter Six · Isolation

Anomalies, Levels and What Your Database Really Does

Isolation controls what a transaction may see of other transactions running at the same time. Each anomaly below is a specific, reproducible wrong answer. Higher isolation prevents more of them and costs more locks, aborts or snapshot overhead.

AnomalyWhat goes wrongExample
Dirty readRead data another transaction has not committed (and may roll back)T2 sets balance 999 uncommitted; T1 reads 999; T2 rolls back
Non-repeatable readRe-reading the same row returns a different valueT1 reads balance 100; T2 commits 70; T1 reads 70
Phantom readRe-running the same range query returns different rowsT1 counts accounts > 40 = 2; T2 inserts one; T1 counts 3
Lost updateTwo read-modify-write cycles; the second overwrites the firstBoth read 100; one writes 70, the other writes 50
Write skewTwo transactions read overlapping data, write different rows, breaking an invariantTwo doctors both go off-call because each saw the other on-call
Level (Spring Isolation)DirtyNon-repeatablePhantomNotes
READ_UNCOMMITTEDpossiblepossiblepossibleAlmost never used; PostgreSQL treats it as READ COMMITTED
READ_COMMITTEDpreventedpossiblepossibleDefault in PostgreSQL, Oracle, SQL Server (locking mode)
REPEATABLE_READpreventedpreventedpossible per SQL standardDefault in MySQL InnoDB. PostgreSQL and InnoDB use MVCC snapshots, so plain reads see no phantoms either
SERIALIZABLEpreventedpreventedpreventedBehaves as if transactions ran one at a time; PostgreSQL aborts conflicting ones (SQLSTATE 40001)
DEFAULT———Whatever the database uses: the most common choice and the one you should know by name
The same two transactions at two isolation levels
READ COMMITTED: each statement sees the latest committed data T1 T2 read A → 100 read A → 70 (changed!) update A=70; COMMIT visible REPEATABLE READ (snapshot): T1 keeps its first view for the whole transaction T1 T2 read A → 100 read A → 100 (same) update A=70; COMMIT not visible to T1

A small in-memory model of the three readers (full source in Chapter 11) reproduces each anomaly on demand:

Output from IsolationDemo
-- isolation READ_UNCOMMITTED T1 reads acc-A = 100, accounts above 40 = 2 T2 writes acc-A=999 (uncommitted); T1 re-reads acc-A = 999 <- DIRTY READ T2 commits acc-A=70 and inserts acc-C=80 T1 re-reads acc-A = 70 <- NON-REPEATABLE READ T1 recounts above 40 = 3 <- PHANTOM -- isolation READ_COMMITTED T1 reads acc-A = 100, accounts above 40 = 2 T2 writes acc-A=999 (uncommitted); T1 re-reads acc-A = 100 T2 commits acc-A=70 and inserts acc-C=80 T1 re-reads acc-A = 70 <- NON-REPEATABLE READ T1 recounts above 40 = 3 <- PHANTOM -- isolation REPEATABLE_READ T1 reads acc-A = 100, accounts above 40 = 2 T2 writes acc-A=999 (uncommitted); T1 re-reads acc-A = 100 T2 commits acc-A=70 and inserts acc-C=80 T1 re-reads acc-A = 100 T1 recounts above 40 = 2 -- lost update vs @Version user1 saves 100-30 : true user2 saves 100-50 : false <- 0 rows updated, ObjectOptimisticLockingFailureException final balance = 70, version = 1
Choosing isolation in Spring, and retrying a serialization failure
@Transactional(isolation = Isolation.SERIALIZABLE) public void closeAccountingPeriod(YearMonth period) { ... } @Retryable(retryFor = {CannotSerializeTransactionException.class, DeadlockLoserDataAccessException.class}, maxAttempts = 3, backoff = @Backoff(delay = 50, multiplier = 2)) @Transactional(isolation = Isolation.SERIALIZABLE) public void reserveSeats(long showId, int n) { ... } // may be aborted by the database: retry the WHOLE transaction
Pick the tool: for lost updates you usually do not need SERIALIZABLE
-- atomic update, no read-modify-write cycle at all update account set balance = balance - 100 where id = 1 and balance >= 100; -- check the update count -- or lock the row you are about to modify select * from account where id = 1 for update; -- or optimistic: update ... set balance = ?, version = version + 1 where id = ? and version = ?
⚠ Common bugs & edge cases:
  • Setting isolation on an inner method that joins an existing transaction. The isolation level only applies when a new transaction begins; a participating call is ignored unless validateExistingTransaction is enabled, in which case it throws IllegalTransactionStateException: Participating transaction with definition [...] specifies isolation level which is incompatible with existing transaction.
  • Assuming REPEATABLE_READ means the same everywhere. MySQL InnoDB combines snapshot reads with gap locks for locking reads; PostgreSQL gives snapshot isolation with could not serialize access due to concurrent update errors on write conflicts. Test on your actual database.
  • Check-then-act race: if (!repo.existsByEmail(e)) repo.save(new User(e)) is racy at every level below SERIALIZABLE. The real guard is a unique constraint; catch DataIntegrityViolationException.
  • Using SERIALIZABLE everywhere for "safety": throughput collapses and 40001 aborts appear. Raise isolation only for the few transactions that need it and retry them.
  • Reading in one transaction and writing in another (two separate @Transactional calls in a controller) opens a window for every anomaly above. Keep a use case in one transaction.
07
Chapter Seven · readOnly & Timeouts

Keeping Transactions Short and Cheap

Two attributes control the cost of a transaction rather than its correctness: readOnly and timeout. Mis-sized transactions are the number one cause of connection-pool exhaustion.

readOnly = true does...Where
Sets Hibernate FlushMode.MANUAL for the sessionHibernateJpaDialect: no dirty checking or flush at commit, so large result sets cost less
Calls Connection.setReadOnly(true)JDBC driver / database may reject writes (PostgreSQL: cannot execute INSERT in a read-only transaction) or route to a replica
Is a hint for the outermost transactionIgnored when joining an existing read-write transaction
Does not make Hibernate throw on writesWith a driver that ignores read-only, changes are just never flushed: silent data loss, not an error
Class-level read-only default, explicit write methods, and a timeout
@Service @Transactional(readOnly = true) public class ReportService { public List<SalesRow> monthly(YearMonth m) { return repo.aggregate(m.atDay(1), m.atEndOfMonth()); } @Transactional(timeout = 10) // seconds, enforced by the transaction manager / driver public void rebuildSnapshot() { ... } // read-write again, bounded }
A transaction holds a connection
▶
From begin until commit/rollback (and with open-in-view even longer, see the data page). A pool of 10 serves 10 simultaneous transactions, no more.
A slow call inside the transaction
▶
An HTTP call, a message send or Thread.sleep holds the connection and any row locks while doing nothing database-related.
Slow call x concurrency > pool size
▶
Other requests block in HikariPool.getConnection until connectionTimeout (30 s) and fail: HikariPool-1 - Connection is not available, request timed out after 30000ms.
The visible symptom is somewhere else
▶
The failing endpoint is a fast one that merely could not get a connection. Check thread dumps for threads waiting on the pool and for the transactional methods that hold connections.
Do slow work outside the transaction boundary
public Receipt checkout(Cart cart) { PaymentResult pay = paymentGateway.charge(cart.total()); // slow remote call: NO transaction open here return orderTx.persistOrder(cart, pay); // short @Transactional method on another bean }
⚠ Common bugs & edge cases:
  • readOnly = true on a method that writes and "works in tests" on H2 but fails on PostgreSQL, or silently persists nothing: pick the right attribute per method.
  • Timeout expectations. timeout is checked when statements run and when the transaction ends; a CPU loop in Java does not get interrupted.
  • One giant transaction around a batch job keeps locks and a huge undo log; commit in chunks (TransactionTemplate per chunk).
  • Remote call after the DB write, inside the transaction. If the remote call succeeds and the commit later fails, you have told the world about something that never happened (see Chapter 10).
  • Lock waits shown as slow queries. Check pg_stat_activity / SHOW ENGINE INNODB STATUS for sessions "idle in transaction": that is a leaked or long transaction.
08
Chapter Eight · Proxy Traps

Self-Invocation, Visibility and Threads

Because @Transactional lives in a proxy, a call that does not pass through the proxy gets no transaction. Every item below is the same bug in a different costume.

Self-invocation: the call that never meets the proxy
Controller CGLIB proxytx logic lives here target bean (the real object) a() @Transactional b() @Transactional(REQUIRES_NEW) service.a() begin tx, call this.b() a() runs inside the proxy: transaction OK. this.b() jumps inside the target object: the proxy is never involved, so REQUIRES_NEW is ignored and b() simply joins a() (or has no tx at all).
Broken and three fixes
// BROKEN: this.audit() bypasses the proxy -> no new transaction, no rollback isolation @Service public class OrderService { @Transactional public void place(Order o) { orders.save(o); this.audit(o); } @Transactional(propagation = Propagation.REQUIRES_NEW) public void audit(Order o) { ... } } // FIX 1 (best): move the method to another bean; the call crosses a proxy boundary @Service public class AuditService { @Transactional(propagation = Propagation.REQUIRES_NEW) public void audit(Order o) { ... } } // FIX 2: programmatic, explicit and visible @Service public class OrderService { private final TransactionTemplate requiresNew; public OrderService(PlatformTransactionManager tm) { this.requiresNew = new TransactionTemplate(tm); this.requiresNew.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); } @Transactional public void place(Order o) { orders.save(o); requiresNew.executeWithoutResult(status -> auditRepo.save(new AuditEntry(o))); } } // FIX 3: AspectJ weaving (@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)) - works on this-calls, needs a weaver
SituationWhy no transactionFix
this.method() / calling another method of the same classNo proxy involvedSplit bean, TransactionTemplate, AspectJ
private or final method / final classCGLIB cannot override itMake non-private/non-final; Kotlin: allopen plugin
new OrderService() instead of injectionNot a Spring beanInject the bean
@PostConstruct / InitializingBean methodRuns before the proxy is appliedListen to ApplicationReadyEvent or use TransactionTemplate
@Async, CompletableFuture.supplyAsync, parallel streams, ExecutorServiceOther thread = other ThreadLocalsOpen a transaction inside the task (a @Transactional method on a bean it calls)
Bean not created by Spring (e.g. in a @Bean method of a BeanPostProcessor)Early beans may miss post-processingCheck log line is not eligible for getting processed by all BeanPostProcessors
Class annotated, called via the concrete type from an interface-proxied beanJDK proxy cannot be cast to the classInject the interface or use CGLIB (Boot default)
TransactionTemplate: transactions you can see
@Service public class ImportService { private final TransactionTemplate tx; public ImportService(PlatformTransactionManager tm) { this.tx = new TransactionTemplate(tm); } public int run(List<Chunk> chunks) { int imported = 0; for (Chunk chunk : chunks) { // one short transaction per chunk imported += tx.execute(status -> { int n = writer.write(chunk); if (n == 0) status.setRollbackOnly(); // explicit rollback, no exception needed return n; }); } return imported; } }
⚠ Common bugs & edge cases:
  • "It works in the unit test, not in production." A plain unit test calls the real object (no proxy), so a self-call "works" because there is no transaction anywhere. Verify with a Spring integration test and TransactionSynchronizationManager.isActualTransactionActive().
  • Self-injection (@Autowired OrderService self) works but hides a design smell and is a circular reference that Boot 2.6+ forbids by default (spring.main.allow-circular-references). Prefer a new bean.
  • @Async + @Transactional on the same method does start a transaction in the async thread, but the caller's transaction is not inherited and may not be committed yet when the async code reads its data.
  • Debugging: in the debugger, this.getClass() showing OrderService$$SpringCGLIB$$0 means you hold the proxy; if the name has no $$ you are on the raw target.
09
Chapter Nine · Events & Async

Doing Side Effects Only After a Successful Commit

Side effects (e-mails, messages, cache refreshes) must happen only if the transaction commits. Sending inside the method sends for transactions that later roll back; sending after the method returns loses it if the process dies. Spring offers transaction-bound synchronisation for this.

Domain event published inside, handled only after commit
public record OrderPlaced(long orderId, String email) {} @Service public class OrderService { private final OrderRepository orders; private final ApplicationEventPublisher events; public OrderService(OrderRepository orders, ApplicationEventPublisher events) { this.orders = orders; this.events = events; } @Transactional public long place(OrderRequest r) { Order o = orders.save(Order.from(r)); events.publishEvent(new OrderPlaced(o.getId(), r.email())); // listener is NOT called yet return o.getId(); } // commit happens here, then AFTER_COMMIT listeners run } @Component class OrderMailer { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) // the default phase public void on(OrderPlaced e) { mailer.send(e.email(), "Order " + e.orderId() + " confirmed"); } @Async @TransactionalEventListener // do not block the committing thread public void index(OrderPlaced e) { search.index(e.orderId()); } }
PhaseRunsCan still affect the tx?Use
BEFORE_COMMITjust before commit, same transactionyes (failure rolls back)Last-moment validation/enrichment
AFTER_COMMIT (default)after a successful commitno; transaction resources still bound but committedE-mail, messages, cache refresh
AFTER_ROLLBACKafter rollbacknoCompensation, metrics
AFTER_COMPLETIONafter eithernoCleanup
Plain @EventListener
▶
Runs synchronously inside the publisher's transaction, regardless of outcome. A listener failure rolls the publisher back.
@TransactionalEventListener with no active transaction
▶
The event is dropped silently unless fallbackExecution = true. A common "my listener never fires" cause.
AFTER_COMMIT listener writing to the database
▶
Needs its own transaction: annotate the handler with @Transactional(propagation = REQUIRES_NEW), otherwise the write is not committed (the original transaction is already completed).
If the process dies right after commit
▶
The AFTER_COMMIT side effect is lost. For guaranteed delivery use an outbox (Chapter 10).
Low-level: TransactionSynchronization
@Transactional public void update(Product p) { repo.save(p); TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { cache.evict(p.getId()); } // evict only if the update really committed }); }
⚠ Common bugs & edge cases:
  • Cache evicted inside the transaction. Another thread reloads the old value before commit and re-caches it: stale cache forever. Evict after commit.
  • Listener not firing because publishing happens from a non-transactional method or from an @Async thread.
  • Exceptions in AFTER_COMMIT listeners are logged and swallowed; the publisher already returned successfully. Do not rely on them for correctness.
  • @Async without enabling it (@EnableAsync) runs synchronously; with it, the handler runs on another thread with no transaction and no SecurityContext unless you propagate it.
10
Chapter Ten · Beyond One Database

2PC, the Transactional Outbox and Sagas

A local transaction covers one resource. Writing to your database and sending a Kafka message and calling a payment API cannot be one ACID transaction; each can fail independently, and "commit the DB, then send" or "send, then commit the DB" both have a failure window. Three families of answers exist.

ApproachIdeaConsistencyCost
Two-phase commit (JTA / XA)A coordinator asks all resources to prepare, then commitAtomic across resourcesBlocking, needs XA drivers, slow, coordinator is a single point of failure; many brokers and cloud services do not support XA
Transactional outboxWrite the message to an outbox table in the same local transaction; a relay publishes it laterAt-least-once delivery, eventually consistentExtra table and relay; consumers must be idempotent
SagaA sequence of local transactions with compensating actions for failuresEventually consistent, no isolation between stepsCompensation logic, intermediate states visible
Transactional outbox: the message becomes part of the database commit
ONE local DB transaction INSERT orders (...) INSERT outbox (topic, payload) COMMIT: both rows or neither Relaypoll or CDC reads BrokerKafka / SQS publish then mark outbox row sent (or delete) Crash between publish and mark = published twice → consumer must be idempotent
Outbox write in the business transaction, plus a polling relay
@Entity @Table(name = "outbox") public class OutboxMessage { @Id @GeneratedValue private Long id; private String aggregateId; private String topic; @Column(columnDefinition = "text") private String payload; private Instant createdAt = Instant.now(); private Instant sentAt; protected OutboxMessage() {} public OutboxMessage(String aggregateId, String topic, String payload) { this.aggregateId = aggregateId; this.topic = topic; this.payload = payload; } public void markSent() { this.sentAt = Instant.now(); } } @Service public class OrderService { @Transactional public void place(OrderRequest r) { Order o = orders.save(Order.from(r)); outbox.save(new OutboxMessage(o.getId().toString(), "orders.placed", json.write(new OrderPlaced(o.getId())))); } // order + outbox row commit together } @Component public class OutboxRelay { @Scheduled(fixedDelay = 500) @Transactional public void publishPending() { for (OutboxMessage m : outbox.lockNextBatch()) { // select ... where sent_at is null order by id limit 100 for update skip locked kafka.send(m.getTopic(), m.getAggregateId(), m.getPayload()).join(); // wait for broker ack m.markSent(); // committed with the loop; crash before commit = re-sent (at-least-once) } } }
Idempotent consumer: the other half of at-least-once
@KafkaListener(topics = "orders.placed") @Transactional public void handle(OrderPlacedMessage m) { if (processed.existsById(m.messageId())) return; // already handled: ignore the duplicate shipping.createShipment(m.orderId()); processed.save(new ProcessedMessage(m.messageId())); // same transaction as the effect; unique key protects races }
⚠ Common bugs & edge cases:
  • "Send to Kafka inside the @Transactional method." Either the send succeeds and the commit fails (phantom event), or the commit succeeds and the send throws after: two failure modes, no atomicity.
  • Polling relay without locking. Two relay instances send every message twice; use FOR UPDATE SKIP LOCKED or a single leader, or a CDC tool reading the log.
  • Outbox table grows forever. Delete or archive sent rows, and index sent_at/id so the poll stays cheap.
  • Saga with no compensation for the last step. Design the failure path for every step first (refund, release stock, cancel shipment); idempotent compensations are as important as idempotent actions.
  • Ordering assumptions. Per-aggregate order is only preserved if the broker partition key is the aggregate id and the relay sends in id order.
11
Chapter Eleven · Build It Yourself

A Transaction Proxy and Isolation Model in Plain Java

Two programs, no Spring. MiniTx builds the whole mechanism of this page: a @Tx annotation, a JDK dynamic proxy that acts as the TransactionInterceptor, a ThreadLocal holding the current "physical" transaction, propagation REQUIRED / REQUIRES_NEW / MANDATORY, the default rollback rule and the rollback-only flag. Compile with javac MiniTx.java and run java MiniTx.

MiniTx.java (complete)
import java.lang.annotation.*; import java.lang.reflect.*; import java.util.*; public class MiniTx { enum Propagation { REQUIRED, REQUIRES_NEW, MANDATORY } @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @interface Tx { Propagation value() default Propagation.REQUIRED; Class<? extends Throwable>[] rollbackFor() default {}; } static class UnexpectedRollbackException extends RuntimeException { UnexpectedRollbackException(String m) { super(m); } } static class IllegalTransactionStateException extends RuntimeException { IllegalTransactionStateException(String m) { super(m); } } // ---- the "connection": a physical transaction ---- static class Physical { static int seq = 0; final int id = ++seq; boolean rollbackOnly; Physical() { System.out.println(" [tx" + id + "] BEGIN"); } } // ---- transaction manager: current tx lives in a ThreadLocal ---- static final ThreadLocal<Physical> CURRENT = new ThreadLocal<>(); static Object invokeInTx(Tx tx, Callable body) throws Throwable { Physical outer = CURRENT.get(); Physical mine; boolean owner; Physical suspended = null; switch (tx.value()) { case REQUIRED -> { if (outer != null) { mine = outer; owner = false; System.out.println(" (joining tx" + outer.id + ")"); } else { mine = new Physical(); owner = true; CURRENT.set(mine); } } case REQUIRES_NEW -> { suspended = outer; if (outer != null) System.out.println(" (suspending tx" + outer.id + ")"); mine = new Physical(); owner = true; CURRENT.set(mine); } default -> { if (outer == null) throw new IllegalTransactionStateException("No existing transaction found for transaction marked with propagation 'mandatory'"); mine = outer; owner = false; } } Object result; try { result = body.call(); } catch (Throwable t) { boolean rollback = t instanceof RuntimeException || t instanceof Error; for (Class<?> c : tx.rollbackFor()) if (c.isInstance(t)) rollback = true; finish(mine, owner, suspended, rollback); throw t; } finish(mine, owner, suspended, false); // may throw UnexpectedRollbackException return result; } static Object finish(Physical p, boolean owner, Physical suspended, boolean rollback) { try { if (!owner) { if (rollback) { p.rollbackOnly = true; System.out.println(" (participant failed: tx" + p.id + " marked rollback-only)"); } return null; } if (rollback || p.rollbackOnly) { System.out.println(" [tx" + p.id + "] ROLLBACK"); if (!rollback) throw new UnexpectedRollbackException("Transaction silently rolled back because it has been marked as rollback-only"); } else System.out.println(" [tx" + p.id + "] COMMIT"); return null; } finally { if (owner) { if (suspended != null) { CURRENT.set(suspended); System.out.println(" (resuming tx" + suspended.id + ")"); } else CURRENT.remove(); } } } interface Callable { Object call() throws Throwable; } // ---- proxy factory: JDK dynamic proxy that wraps @Tx methods ---- @SuppressWarnings("unchecked") static <T> T proxy(Class<T> iface, T target) { return (T) Proxy.newProxyInstance(iface.getClassLoader(), new Class<?>[]{iface}, (p, m, a) -> { Method impl = target.getClass().getMethod(m.getName(), m.getParameterTypes()); Tx tx = impl.getAnnotation(Tx.class); Callable body = () -> { try { return impl.invoke(target, a); } catch (InvocationTargetException e) { throw e.getCause(); } }; return tx == null ? body.call() : invokeInTx(tx, body); }); } // ---- application code ---- interface OrderService { void placeOrder(boolean failAudit); void audit(boolean fail); void checkedFailure() throws Exception; void innerFails(); void outerSwallows(); void outerCallsThis(); } interface Auditor { void write(boolean fail); } static class AuditImpl implements Auditor { @Tx(Propagation.REQUIRES_NEW) public void write(boolean fail) { System.out.println(" audit.write in tx" + CURRENT.get().id); if (fail) throw new IllegalStateException("audit down"); } } static Auditor auditor = proxy(Auditor.class, new AuditImpl()); static OrderService self; // the proxy, as Spring would inject it static class OrderImpl implements OrderService { @Tx public void placeOrder(boolean failAudit) { System.out.println(" placeOrder in tx" + CURRENT.get().id); try { auditor.write(failAudit); } catch (IllegalStateException e) { System.out.println(" placeOrder caught: " + e.getMessage()); } } @Tx public void audit(boolean fail) { System.out.println(" audit sees tx: " + (CURRENT.get() == null ? "none" : "tx" + CURRENT.get().id)); } @Tx public void checkedFailure() throws Exception { throw new Exception("checked"); } @Tx public void innerFails() { throw new IllegalArgumentException("inner boom"); } @Tx public void outerSwallows() { try { self.innerFails(); } catch (IllegalArgumentException e) { System.out.println(" outer swallowed: " + e.getMessage()); } } public void outerCallsThis() { // no @Tx here, and calls this.audit -> bypasses proxy this.audit(false); System.out.println(" tx active? " + (CURRENT.get() != null)); } } public static void main(String[] args) throws Exception { self = proxy(OrderService.class, new OrderImpl()); System.out.println("A. REQUIRED + REQUIRES_NEW, audit succeeds"); self.placeOrder(false); System.out.println("B. audit (REQUIRES_NEW) fails and is caught: outer still commits"); self.placeOrder(true); System.out.println("C. checked exception: default rule COMMITS"); try { self.checkedFailure(); } catch (Exception e) { System.out.println(" caller saw: " + e.getMessage()); } System.out.println("D. inner REQUIRED fails, outer swallows: rollback-only trap"); try { self.outerSwallows(); } catch (UnexpectedRollbackException e) { System.out.println(" caller saw: " + e.getMessage()); } System.out.println("E. self-invocation: this.audit() never sees a transaction"); new OrderImpl().outerCallsThis(); } }
Real output
A. REQUIRED + REQUIRES_NEW, audit succeeds [tx1] BEGIN placeOrder in tx1 (suspending tx1) [tx2] BEGIN audit.write in tx2 [tx2] COMMIT (resuming tx1) [tx1] COMMIT B. audit (REQUIRES_NEW) fails and is caught: outer still commits [tx3] BEGIN placeOrder in tx3 (suspending tx3) [tx4] BEGIN audit.write in tx4 [tx4] ROLLBACK (resuming tx3) placeOrder caught: audit down [tx3] COMMIT C. checked exception: default rule COMMITS [tx5] BEGIN [tx5] COMMIT caller saw: checked D. inner REQUIRED fails, outer swallows: rollback-only trap [tx6] BEGIN (joining tx6) (participant failed: tx6 marked rollback-only) outer swallowed: inner boom [tx6] ROLLBACK caller saw: Transaction silently rolled back because it has been marked as rollback-only E. self-invocation: this.audit() never sees a transaction audit sees tx: none tx active? false
A and B
▶
REQUIRES_NEW suspends tx1 (CURRENT is replaced), commits or rolls back tx2 on its own, and resumes tx1. The failing audit did not affect the outer commit.
C
▶
The checked Exception failed the "RuntimeException or Error" test, so the proxy committed, exactly like Spring.
D
▶
The inner participant could not roll back (it does not own the transaction) so it set rollbackOnly; the owner then rolled back and threw UnexpectedRollbackException.
E
▶
new OrderImpl().outerCallsThis() never touches the proxy, so no transaction: tx active? false. Self-invocation in 3 lines.

The second program models the three readers from the isolation chapter plus optimistic locking, so you can see each anomaly appear and disappear.

IsolationDemo.java (complete)
import java.util.*; public class IsolationDemo { enum Level { READ_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READ } static class Store { final Map<String, Integer> committed = new TreeMap<>(); final Map<String, Integer> uncommitted = new TreeMap<>(); // writes of the open writer } static class Reader { final Store s; final Level level; Map<String, Integer> snap; Reader(Store s, Level l) { this.s = s; this.level = l; } Map<String, Integer> view() { switch (level) { case READ_UNCOMMITTED: { Map<String, Integer> m = new TreeMap<>(s.committed); m.putAll(s.uncommitted); return m; } case READ_COMMITTED: return new TreeMap<>(s.committed); // fresh statement snapshot default: if (snap == null) snap = new TreeMap<>(s.committed); return snap; // tx-level snapshot } } Integer read(String k) { return view().get(k); } long countAbove(int v) { return view().values().stream().filter(x -> x > v).count(); } } static void run(Level level) { System.out.println("-- isolation " + level); Store s = new Store(); s.committed.put("acc-A", 100); s.committed.put("acc-B", 50); Reader r = new Reader(s, level); Integer first = r.read("acc-A"); long cntFirst = r.countAbove(40); System.out.println(" T1 reads acc-A = " + first + ", accounts above 40 = " + cntFirst); s.uncommitted.put("acc-A", 999); // T2 writes, not yet committed System.out.println(" T2 writes acc-A=999 (uncommitted); T1 re-reads acc-A = " + r.read("acc-A") + (r.read("acc-A") == 999 ? " <- DIRTY READ" : "")); s.uncommitted.clear(); s.committed.put("acc-A", 70); // T2 commits an update ... s.committed.put("acc-C", 80); // ... and inserts a new row Integer second = r.read("acc-A"); long cntSecond = r.countAbove(40); System.out.println(" T2 commits acc-A=70 and inserts acc-C=80"); System.out.println(" T1 re-reads acc-A = " + second + (second.equals(first) ? "" : " <- NON-REPEATABLE READ")); System.out.println(" T1 recounts above 40 = " + cntSecond + (cntSecond == cntFirst ? "" : " <- PHANTOM")); } // optimistic locking: UPDATE ... WHERE id=? AND version=? static class Row { int balance = 100; int version = 0; } static boolean update(Row row, int expectedVersion, int newBalance) { if (row.version != expectedVersion) return false; // 0 rows updated -> OptimisticLockingFailureException row.balance = newBalance; row.version++; return true; } public static void main(String[] args) { for (Level l : Level.values()) run(l); System.out.println("-- lost update vs @Version"); Row row = new Row(); int v1 = row.version, v2 = row.version; // both users load version 0, balance 100 System.out.println(" user1 saves 100-30 : " + update(row, v1, 70)); System.out.println(" user2 saves 100-50 : " + update(row, v2, 50) + " <- 0 rows updated, ObjectOptimisticLockingFailureException"); System.out.println(" final balance = " + row.balance + ", version = " + row.version); } }
Real output
-- isolation READ_UNCOMMITTED T1 reads acc-A = 100, accounts above 40 = 2 T2 writes acc-A=999 (uncommitted); T1 re-reads acc-A = 999 <- DIRTY READ T2 commits acc-A=70 and inserts acc-C=80 T1 re-reads acc-A = 70 <- NON-REPEATABLE READ T1 recounts above 40 = 3 <- PHANTOM -- isolation READ_COMMITTED T1 reads acc-A = 100, accounts above 40 = 2 T2 writes acc-A=999 (uncommitted); T1 re-reads acc-A = 100 T2 commits acc-A=70 and inserts acc-C=80 T1 re-reads acc-A = 70 <- NON-REPEATABLE READ T1 recounts above 40 = 3 <- PHANTOM -- isolation REPEATABLE_READ T1 reads acc-A = 100, accounts above 40 = 2 T2 writes acc-A=999 (uncommitted); T1 re-reads acc-A = 100 T2 commits acc-A=70 and inserts acc-C=80 T1 re-reads acc-A = 100 T1 recounts above 40 = 2 -- lost update vs @Version user1 saves 100-30 : true user2 saves 100-50 : false <- 0 rows updated, ObjectOptimisticLockingFailureException final balance = 70, version = 1
Extend it
  • Add NESTED with a savepoint stack and show an inner failure rolling back only its own changes.
  • Make CURRENT an InheritableThreadLocal or run the callee on another thread to demonstrate why @Async loses the transaction.
  • Add a timeout attribute checked in finish().
12
Chapter Twelve · Interview Angles

Questions and the Follow-Up Trap

Crisp answers with the follow-up that usually decides the interview.

  • How does @Transactional work?
    A proxy (CGLIB by default in Boot) wraps the bean; TransactionInterceptor asks the PlatformTransactionManager to begin, binds resources to the thread, proceeds, then commits or rolls back. Trap: calls that bypass the proxy get no transaction.
  • Why does a self-invoked @Transactional method not work?
    The call goes this.method() directly to the target, skipping the proxy. Move it to another bean, use TransactionTemplate, or AspectJ mode. Trap: unit tests pass because nothing is transactional anyway.
  • Does a checked exception roll back?
    No; only RuntimeException and Error by default. Use rollbackFor. Trap: wrapping an unchecked into a checked silently turns rollback into commit.
  • Explain REQUIRED vs REQUIRES_NEW vs NESTED.
    REQUIRED joins or creates; REQUIRES_NEW suspends and runs independently with its own commit; NESTED uses a savepoint inside the outer transaction. Trap: REQUIRES_NEW needs a second connection (pool deadlock) and cannot see uncommitted outer data.
  • What is UnexpectedRollbackException?
    The outer transaction commits but a participating inner call had marked it rollback-only. Trap: catching the inner exception does not clear the flag.
  • List the isolation anomalies.
    Dirty read, non-repeatable read, phantom read; plus lost update and write skew in practice. Trap: the SQL standard says REPEATABLE_READ allows phantoms, but PostgreSQL/InnoDB snapshots prevent them for plain reads.
  • What isolation level do PostgreSQL and MySQL default to?
    PostgreSQL: READ COMMITTED; MySQL InnoDB: REPEATABLE READ. Trap: the same code behaves differently across databases.
  • What does readOnly = true do?
    Hint: Hibernate flush mode MANUAL and setReadOnly on the connection. Trap: not enforced everywhere; it does not prevent writes by itself and is ignored when joining a read-write transaction.
  • Can @Transactional work on private methods or in @PostConstruct?
    No: CGLIB cannot override private methods and the proxy does not exist during initialisation. Trap: protected/package methods work from Spring 6.0 for class-based proxies.
  • Why can transactions and @Async not share state?
    Transaction resources are in ThreadLocals; the new thread has none. Trap: parallel streams and executors have the same effect.
  • How do you send a message only if the DB commit succeeds?
    Transactional outbox (or @TransactionalEventListener(AFTER_COMMIT) for best effort). Trap: AFTER_COMMIT is lost on a crash; outbox consumers must be idempotent.
  • Why are long transactions harmful?
    They hold a pooled connection and row locks, causing pool exhaustion and lock waits. Trap: remote calls inside the transaction and open-in-view extend the hold time.
  • How do you avoid lost updates?
    Atomic updates, @Version, SELECT ... FOR UPDATE, or SERIALIZABLE with retry. Trap: READ COMMITTED does not prevent read-modify-write races.
13
Chapter Thirteen · Summary

Cheat Sheet and What to Read Next

SymptomLikely causeFirst move
Nothing is rolled backChecked exception, or exception swallowed, or no proxy (self-call)rollbackFor; rethrow; check isActualTransactionActive()
UnexpectedRollbackExceptionInner participating call failed and was caughtNESTED/REQUIRES_NEW for recoverable steps; do not swallow
LazyInitializationExceptionLazy access after transaction endedFetch inside the service; return DTOs
Connection is not availableLong or nested transactions exhaust the poolShorten transactions, move remote calls out, check REQUIRES_NEW depth
Audit row lost on rollbackAudit joined the outer transactionSeparate bean + REQUIRES_NEW
Listener never firesNo active transaction at publish, or wrong phasefallbackExecution, publish inside a transaction
Duplicate messages downstreamOutbox at-least-onceIdempotent consumer with processed-id table
Different results on another databaseDifferent default isolationSet isolation explicitly where it matters and test on the real DB

Takeaways

  • @Transactional is a proxy-based around advice; the transaction is bound to the current thread.
  • Default rollback: RuntimeException and Error only. Use rollbackFor = Exception.class deliberately.
  • Propagation: REQUIRED joins; REQUIRES_NEW is a separate transaction and connection; NESTED is a savepoint; MANDATORY/NEVER are guards.
  • An inner failure on a joined transaction marks it rollback-only: catching it later still ends in UnexpectedRollbackException.
  • Isolation trades anomalies for concurrency; know your database default and prefer atomic updates, @Version or constraints over higher isolation.
  • Keep transactions short: no remote calls inside, one use case per transaction, readOnly for queries.
  • Self-invocation, private methods, @PostConstruct and other threads get no transaction. Split beans or use TransactionTemplate.
  • For DB + messaging use the transactional outbox with idempotent consumers; for multi-service workflows use sagas.