@Transactional is one annotation hiding two deep, easy-to-misuse dials: propagation and isolation. The bugs they cause are the silent kind — an audit row that vanishes on rollback, a "saved" record that isn't. So I built a tool that shows the outcome of each choice instead of making you guess.
▶ Live demo: https://dev48v.github.io/transaction-visualizer/
Source (single file, zero deps): https://github.com/dev48v/transaction-visualizer
Propagation: who commits, who rolls back
A scenario you'll recognize: placeOrder() is @Transactional, saves an order, then calls audit.log(). Pick log()'s propagation and toggle "audit throws" / "outer throws after audit", and watch which of the two writes survives.
The one that surprises everyone:
@Transactional // REQUIRED — one shared tx
void placeOrder() {
orderRepo.save(order);
audit.log(); // ...propagation matters here
throw new RuntimeException("boom"); // outer fails AFTER audit
}
@Transactional(propagation = REQUIRES_NEW) // independent tx
void log() { auditRepo.save(entry); }
With REQUIRES_NEW, log() suspends the caller's transaction, runs its own, and commits independently. So when placeOrder() rolls back, the order is gone but the audit row survives. Switch it to the default REQUIRED and the audit joins the same transaction — now it rolls back with everything else. Same code, opposite result.
Quick tour of the rest:
-
REQUIRED(default) — join the caller's tx; any failure rolls back everything. Once it's marked rollback-only, even catching the exception can't save it (helloUnexpectedRollbackException). -
NESTED— a savepoint; a caught inner failure does a partial rollback and the outer still commits (needs a savepoint-capableDataSourceTransactionManager— JPA/Hibernate often can't). -
NOT_SUPPORTED— suspend the tx and run with none (auto-commit); the write persists immediately and independently. -
MANDATORY/NEVER— assert a tx must / must not already exist, else throw.
And the gotcha no propagation setting fixes: self-invocation bypasses the proxy. Calling
this.log()from inside the same bean ignores its@Transactionalcompletely, because the call never goes through Spring's proxy.
Isolation: which anomalies leak
The second tab is a matrix of the four standard levels against the three read anomalies, with a concrete two-transaction (T1/T2) walkthrough for the level you pick:
| Level | Dirty read | Non-repeatable read | Phantom read |
|---|---|---|---|
| READ_UNCOMMITTED | ✗ possible | ✗ possible | ✗ possible |
| READ_COMMITTED | ✓ prevented | ✗ possible | ✗ possible |
| REPEATABLE_READ | ✓ prevented | ✓ prevented | ✗ possible |
| SERIALIZABLE | ✓ prevented | ✓ prevented | ✓ prevented |
Seeing T1 read 100, T2 commit 200, and T1 read 200 again — a non-repeatable read under READ_COMMITTED — makes the abstract table concrete.
One index.html, no build, works offline. If it untangled @Transactional for you, a star helps others find it: https://github.com/dev48v/transaction-visualizer
Top comments (0)