ACID, the @Transactional proxy, all seven propagation levels, rollback rules, isolation anomalies, proxy traps, events and outbox, with runnable plain-Java models.
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.
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
publicvoidtransfer(long from, long to, BigDecimal amount) throwsSQLException {
Connection con = dataSource.getConnection();
try {
con.setAutoCommit(false);
debit(con, from, amount); // every helper must receive the SAME connectioncredit(con, to, amount);
con.commit();
} catch (Exception e) {
con.rollback();
throw e;
} finally {
con.setAutoCommit(true);
con.close();
}
}
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)
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.
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.
Seconds before the transaction is marked rollback-only/time-out
rollbackFor / rollbackForClassName
RuntimeException, Error
Extra exception types that force rollback
noRollbackFor
none
Unchecked exceptions that should still commit
transactionManager / label
the single manager
Select 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 txpublicclassCatalogService {
publicProductfind(long id) { ... } // inherits readOnly = true@Transactional// method level overrides class level (readOnly back to false)publicProductrename(long id, String name) { ... }
@Transactional(propagation = Propagation.REQUIRES_NEW, timeout = 5)
publicvoidaudit(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
Manager
When Boot picks it
Notes
JpaTransactionManager
spring-data-jpa / hibernate on classpath
Also exposes the JDBC connection, so JdbcTemplate in the same transaction shares it
DataSourceTransactionManager
JDBC only (spring-boot-starter-jdbc)
Connection bound via DataSourceUtils
JtaTransactionManager
You configure it
For 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
Propagation
Tx already exists
No tx exists
Typical use
REQUIRED
join it
create new
Default for service methods
REQUIRES_NEW
suspend it, create new, resume after
create new
Audit log / outbox that must survive caller rollback; independent commit
NESTED
savepoint inside it
create new (like REQUIRED)
Optional step whose failure should not abort the whole unit; needs savepoint-capable manager (JDBC / JPA with JDBC)
SUPPORTS
join it
run without tx
Read helpers that work both ways
NOT_SUPPORTED
suspend it, run without
run without
Call slow or non-transactional resources without holding the tx
MANDATORY
join it
throwIllegalTransactionStateException
Methods that must never be called outside a transaction
NEVER
throwIllegalTransactionStateException
run without
Guards: must not run inside a tx
REQUIRES_NEW for an audit record that must be saved even if the business tx rolls back
@ServicepublicclassOrderService {
privatefinalOrderRepository orders;
privatefinalAuditService audit; // a SEPARATE bean: needed so the call crosses the proxypublicOrderService(OrderRepository orders, AuditService audit) { this.orders = orders; this.audit = audit; }
@Transactionalpublicvoidplace(OrderRequest req) {
audit.record("ORDER_ATTEMPT", req.customerId()); // committed independently, immediately
orders.save(Order.from(req));
if (req.total().signum() <= 0) thrownewIllegalArgumentException("total must be positive"); // rolls back the order only
}
}
@ServicepublicclassAuditService {
privatefinalAuditRepository repo;
publicAuditService(AuditRepository repo) { this.repo = repo; }
@Transactional(propagation = Propagation.REQUIRES_NEW)
publicvoidrecord(String event, long customerId) { repo.save(newAuditEntry(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
@TransactionalpublicvoidimportBatch(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.
Checked, e.g. IOException, custom extends Exception
COMMIT
rollbackFor = Exception.class
Nothing thrown, but setRollbackOnly() called
ROLLBACK
—
Nothing thrown, but a participating inner call failed
ROLLBACK + UnexpectedRollbackException
Use NESTED / REQUIRES_NEW for recoverable inner steps
Rules in code
@Transactional(rollbackFor = Exception.class) // team-wide safe default for servicespublicvoidimportFile(Path p) throwsIOException { ... } // checked IOException now rolls back too@Transactional(noRollbackFor = DuplicateEventException.class)
publicvoidhandle(Event e) { ... } // unchecked, but we WANT to commit what was done so far@Transactionalpublicvoidconditional(Order o) {
if (o.isSuspicious()) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); // roll back without throwingreturn;
}
orders.save(o);
}
The rollback-only trap: catching the exception does not un-fail the transaction
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
@Transactionalpublicvoidregister(User u) {
try {
users.save(u); // IDENTITY: INSERT runs now; SEQUENCE: INSERT is deferred to flush
} catch (DataIntegrityViolationException e) {
thrownewEmailTakenException(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.
Anomaly
What goes wrong
Example
Dirty read
Read data another transaction has not committed (and may roll back)
Choosing isolation in Spring, and retrying a serialization failure
@Transactional(isolation = Isolation.SERIALIZABLE)
publicvoidcloseAccountingPeriod(YearMonth period) { ... }
@Retryable(retryFor = {CannotSerializeTransactionException.class, DeadlockLoserDataAccessException.class},
maxAttempts = 3, backoff = @Backoff(delay = 50, multiplier = 2))
@Transactional(isolation = Isolation.SERIALIZABLE)
publicvoidreserveSeats(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 allupdate account set balance = balance - 100where id = 1and balance >= 100; -- check the update count-- or lock the row you are about to modifyselect * from account where id = 1for 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 session
HibernateJpaDialect: 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 transaction
Ignored when joining an existing read-write transaction
Does not make Hibernate throw on writes
With 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
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
publicReceiptcheckout(Cart cart) {
PaymentResult pay = paymentGateway.charge(cart.total()); // slow remote call: NO transaction open herereturn 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
Broken and three fixes
// BROKEN: this.audit() bypasses the proxy -> no new transaction, no rollback isolation@ServicepublicclassOrderService {
@Transactionalpublicvoidplace(Order o) { orders.save(o); this.audit(o); }
@Transactional(propagation = Propagation.REQUIRES_NEW) publicvoidaudit(Order o) { ... }
}
// FIX 1 (best): move the method to another bean; the call crosses a proxy boundary@ServicepublicclassAuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW) publicvoidaudit(Order o) { ... }
}
// FIX 2: programmatic, explicit and visible@ServicepublicclassOrderService {
privatefinalTransactionTemplate requiresNew;
publicOrderService(PlatformTransactionManager tm) {
this.requiresNew = newTransactionTemplate(tm);
this.requiresNew.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
}
@Transactionalpublicvoidplace(Order o) {
orders.save(o);
requiresNew.executeWithoutResult(status -> auditRepo.save(newAuditEntry(o)));
}
}
// FIX 3: AspectJ weaving (@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)) - works on this-calls, needs a weaver
Situation
Why no transaction
Fix
this.method() / calling another method of the same class
No proxy involved
Split bean, TransactionTemplate, AspectJ
private or final method / final class
CGLIB cannot override it
Make non-private/non-final; Kotlin: allopen plugin
new OrderService() instead of injection
Not a Spring bean
Inject the bean
@PostConstruct / InitializingBean method
Runs before the proxy is applied
Listen to ApplicationReadyEvent or use TransactionTemplate
Open 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-processing
Check log line is not eligible for getting processed by all BeanPostProcessors
Class annotated, called via the concrete type from an interface-proxied bean
JDK proxy cannot be cast to the class
Inject the interface or use CGLIB (Boot default)
TransactionTemplate: transactions you can see
@ServicepublicclassImportService {
privatefinalTransactionTemplate tx;
publicImportService(PlatformTransactionManager tm) { this.tx = newTransactionTemplate(tm); }
publicintrun(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 neededreturn 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
publicrecordOrderPlaced(long orderId, String email) {}
@ServicepublicclassOrderService {
privatefinalOrderRepository orders; privatefinalApplicationEventPublisher events;
publicOrderService(OrderRepository orders, ApplicationEventPublisher events) { this.orders = orders; this.events = events; }
@Transactionalpubliclongplace(OrderRequest r) {
Order o = orders.save(Order.from(r));
events.publishEvent(newOrderPlaced(o.getId(), r.email())); // listener is NOT called yetreturn o.getId();
} // commit happens here, then AFTER_COMMIT listeners run
}
@ComponentclassOrderMailer {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) // the default phasepublicvoidon(OrderPlaced e) { mailer.send(e.email(), "Order " + e.orderId() + " confirmed"); }
@Async@TransactionalEventListener// do not block the committing threadpublicvoidindex(OrderPlaced e) { search.index(e.orderId()); }
}
Phase
Runs
Can still affect the tx?
Use
BEFORE_COMMIT
just before commit, same transaction
yes (failure rolls back)
Last-moment validation/enrichment
AFTER_COMMIT (default)
after a successful commit
no; transaction resources still bound but committed
E-mail, messages, cache refresh
AFTER_ROLLBACK
after rollback
no
Compensation, metrics
AFTER_COMPLETION
after either
no
Cleanup
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
@Transactionalpublicvoidupdate(Product p) {
repo.save(p);
TransactionSynchronizationManager.registerSynchronization(newTransactionSynchronization() {
@OverridepublicvoidafterCommit() { 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.
Approach
Idea
Consistency
Cost
Two-phase commit (JTA / XA)
A coordinator asks all resources to prepare, then commit
Atomic across resources
Blocking, needs XA drivers, slow, coordinator is a single point of failure; many brokers and cloud services do not support XA
Transactional outbox
Write the message to an outbox table in the same local transaction; a relay publishes it later
At-least-once delivery, eventually consistent
Extra table and relay; consumers must be idempotent
Saga
A sequence of local transactions with compensating actions for failures
Eventually consistent, no isolation between steps
Compensation logic, intermediate states visible
Transactional outbox: the message becomes part of the database commit
Outbox write in the business transaction, plus a polling relay
@Entity@Table(name = "outbox")
publicclassOutboxMessage {
@Id@GeneratedValueprivateLong id;
privateString aggregateId; privateString topic;
@Column(columnDefinition = "text") privateString payload;
privateInstant createdAt = Instant.now(); privateInstant sentAt;
protectedOutboxMessage() {}
publicOutboxMessage(String aggregateId, String topic, String payload) { this.aggregateId = aggregateId; this.topic = topic; this.payload = payload; }
publicvoidmarkSent() { this.sentAt = Instant.now(); }
}
@ServicepublicclassOrderService {
@Transactionalpublicvoidplace(OrderRequest r) {
Order o = orders.save(Order.from(r));
outbox.save(newOutboxMessage(o.getId().toString(), "orders.placed", json.write(newOrderPlaced(o.getId()))));
} // order + outbox row commit together
}
@ComponentpublicclassOutboxRelay {
@Scheduled(fixedDelay = 500)
@TransactionalpublicvoidpublishPending() {
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")
@Transactionalpublicvoidhandle(OrderPlacedMessage m) {
if (processed.existsById(m.messageId())) return; // already handled: ignore the duplicate
shipping.createShipment(m.orderId());
processed.save(newProcessedMessage(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.
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.*;
publicclassIsolationDemo {
enumLevel { READ_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READ }
staticclassStore {
finalMap<String, Integer> committed = newTreeMap<>();
finalMap<String, Integer> uncommitted = newTreeMap<>(); // writes of the open writer
}
staticclassReader {
finalStore s; finalLevel level; Map<String, Integer> snap;
Reader(Store s, Level l) { this.s = s; this.level = l; }
Map<String, Integer> view() {
switch (level) {
caseREAD_UNCOMMITTED: { Map<String, Integer> m = newTreeMap<>(s.committed); m.putAll(s.uncommitted); return m; }
caseREAD_COMMITTED: returnnewTreeMap<>(s.committed); // fresh statement snapshotdefault: if (snap == null) snap = newTreeMap<>(s.committed); return snap; // tx-level snapshot
}
}
Integerread(String k) { returnview().get(k); }
longcountAbove(int v) { returnview().values().stream().filter(x -> x > v).count(); }
}
staticvoidrun(Level level) {
System.out.println("-- isolation " + level);
Store s = newStore();
s.committed.put("acc-A", 100); s.committed.put("acc-B", 50);
Reader r = newReader(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 committedSystem.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 rowInteger 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=?staticclassRow { int balance = 100; int version = 0; }
staticbooleanupdate(Row row, int expectedVersion, int newBalance) {
if (row.version != expectedVersion) returnfalse; // 0 rows updated -> OptimisticLockingFailureException
row.balance = newBalance; row.version++; returntrue;
}
publicstaticvoidmain(String[] args) {
for (Level l : Level.values()) run(l);
System.out.println("-- lost update vs @Version");
Row row = newRow();
int v1 = row.version, v2 = row.version; // both users load version 0, balance 100System.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);
}
}
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
Symptom
Likely cause
First move
Nothing is rolled back
Checked exception, or exception swallowed, or no proxy (self-call)