DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

I Built a @Transactional Visualizer for Spring (Propagation + Isolation)

@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); }
Enter fullscreen mode Exit fullscreen mode

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 (hello UnexpectedRollbackException).
  • NESTED — a savepoint; a caught inner failure does a partial rollback and the outer still commits (needs a savepoint-capable DataSourceTransactionManager — 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 @Transactional completely, 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)