What are the exact rollback rules for TransactionTemplate — which exceptions roll back, and how does setRollbackOnly() differ from throwing?
answer
- Unchecked (RuntimeException/Error) → auto rollback + rethrow
- execute catches Throwable → even checked rolls back (UndeclaredThrowableException)
- Contrast @Transactional: commits on checked by default
- setRollbackOnly = rollback but return normally
- Inner rollback-only poisons outer → UnexpectedRollbackException
basics
~10 sIf your callback throws a RuntimeException or Error, TransactionTemplate rolls back and rethrows. If it returns normally, it commits. You can also force rollback without throwing by calling status.setRollbackOnly() and returning normally.
solid answer
~40 sTransactionTemplate rolls back automatically on any unchecked exception — RuntimeException or Error — thrown from the callback, then rethrows it. Normal return commits. A key subtlety: unlike declarative @Transactional (which by default commits on checked exceptions), TransactionTemplate's execute also catches Throwable, so even a sneakily-thrown checked exception triggers rollback and is rewrapped in an UndeclaredThrowableException. The alternative to throwing is status.setRollbackOnly(): it marks the transaction rollback-only so the manager rolls back at the end, but lets your callback return normally without surfacing an exception to the caller. Under nested/participating transactions, an inner setRollbackOnly() poisons the whole physical transaction, and the outer commit fails with UnexpectedRollbackException. Use throwing for real errors, setRollbackOnly for a business decision to abort while returning a value.
code
java · 29 lines// 1) Throwing rolls back AND propagates the error
txTemplate.execute(status -> {
repo.debit(from, amount);
if (repo.balanceOf(from) < 0) {
throw new IllegalStateException("insufficient funds"); // rollback + rethrow
}
repo.credit(to, amount);
return null;
});
// 2) setRollbackOnly rolls back but lets you return a value
boolean applied = txTemplate.execute(status -> {
repo.reserve(itemId);
if (!inventoryOk(itemId)) {
status.setRollbackOnly(); // undo the reserve, no exception thrown
return false;
}
return true;
});
// 3) Poison-pill pitfall: inner sets rollback-only, outer commit fails
txTemplate.executeWithoutResult(outer -> { // PROPAGATION_REQUIRED
try {
txTemplate.executeWithoutResult(inner -> { // joins the SAME tx
inner.setRollbackOnly();
});
} catch (Exception ignored) { /* swallowed */ }
// outer tries to commit -> throws UnexpectedRollbackException
});go deeper
Know unchecked exceptions roll back and normal return commits.
Add setRollbackOnly() as the no-throw rollback, and that Errors roll back too.
Articulate the Throwable-catch behavior, the contrast with @Transactional's checked-exception default, and the swallowed-exception-commits pitfall.
Reason about propagation interactions: rollback-only poisoning, UnexpectedRollbackException, and choosing REQUIRES_NEW/NESTED to bound failure blast radius.
## The default rollback rule With `TransactionTemplate`, rollback happens when: 1. The callback throws a **`RuntimeException`** (any unchecked exception), or 2. The callback throws an **`Error`**, or 3. You call **`status.setRollbackOnly()`** and then return. Commit happens when the callback **returns normally** and rollback-only was **not** set. ## How execute() implements it Simplified from Spring's source (`TransactionTemplate.execute`): ```java T result; try { result = action.doInTransaction(status); } catch (RuntimeException | Error ex) { rollbackOnException(status, ex); // roll back throw ex; // rethrow unchanged } catch (Throwable ex) { rollbackOnException(status, ex); // roll back throw new UndeclaredThrowableException(ex, "TransactionCallback threw undeclared checked exception"); } this.transactionManager.commit(status); return result; ``` **Important consequence:** the third `catch (Throwable)` means that if a checked exception somehow escapes the callback (e.g. via a sneaky-throw / generics trick, or Kotlin where checked exceptions aren't enforced), `TransactionTemplate` **still rolls back** and rewraps it in `UndeclaredThrowableException`. ### Contrast with @Transactional This differs from **declarative** `@Transactional`, whose default `DefaultTransactionAttribute` rolls back only on `RuntimeException`/`Error` and **commits** on checked exceptions (you'd add `rollbackFor = Exception.class` to change that). So the programmatic template is effectively 'rollback on any Throwable', while the annotation is 'rollback on unchecked only, by default'. Interviewers love this asymmetry. Note that the normal `TransactionCallback.doInTransaction` signature only declares `RuntimeException`, so in idiomatic Java you'll usually be throwing unchecked exceptions anyway. ## setRollbackOnly() vs throwing | | Throw an exception | `status.setRollbackOnly()` | |---|---|---| | Transaction outcome | Rollback | Rollback | | Control returns to caller | No — exception propagates | Yes — callback returns normally | | Return value from `execute` | None (throws) | Your returned value | | Use when | A real error occurred | Business rule says 'abort but don't error' | `setRollbackOnly()` sets a flag on the `TransactionStatus`. At the end of `execute`, Spring sees the flag and rolls back instead of committing — silently, from the caller's perspective. ## Nested / participating transactions — the poison pill With `PROPAGATION_REQUIRED`, an inner `TransactionTemplate` call **joins** the outer transaction (`isNewTransaction() == false`). If that inner call sets rollback-only (or throws and the outer swallows the exception), the **shared** physical transaction is now marked rollback-only. When the outer transaction then tries to commit, Spring throws **`UnexpectedRollbackException`** — 'Transaction silently rolled back because it has been marked as rollback-only'. This surprises people who caught the inner exception expecting to continue. To isolate an inner unit so its rollback doesn't doom the outer, use `PROPAGATION_REQUIRES_NEW` (separate physical transaction) or `PROPAGATION_NESTED` (savepoint). ## Edge cases & gotchas - **Swallowing exceptions inside the callback**: if you `try/catch` inside `doInTransaction` and don't rethrow or set rollback-only, the transaction **commits** — the error is invisible to the tx machinery. - **Rollback of the rollback**: if the actual rollback fails at the resource level, Spring may throw a `TransactionSystemException`. - **Errors are rolled back too** — not just RuntimeExceptions. An `OutOfMemoryError` or `AssertionError` from the callback rolls back. - **Read-only transactions** still honor rollback semantics, though there's nothing to undo for pure reads.
- Why does @Transactional commit on a checked exception while TransactionTemplate would roll back if that exception escaped the callback?Declarative @Transactional uses rollback rules from DefaultTransactionAttribute, which by default rolls back only on unchecked exceptions/Errors and commits on checked ones (you opt in with rollbackFor). TransactionTemplate.execute instead catches Throwable and rolls back on anything, rewrapping a checked exception in UndeclaredThrowableException. Different default policies.
- An inner participating transaction failed, you caught the exception, and now the outer commit throws UnexpectedRollbackException. Why, and how do you avoid it?With PROPAGATION_REQUIRED the inner call joined the outer physical transaction and marked it rollback-only; that flag can't be un-set, so the outer commit is forced to roll back and Spring signals it with UnexpectedRollbackException. To isolate the inner unit, run it with PROPAGATION_REQUIRES_NEW (independent transaction) or PROPAGATION_NESTED (savepoint).
saying these in an interview costs you the question
- Saying checked exceptions always commit with TransactionTemplate just like @Transactional
- Claiming setRollbackOnly() throws an exception to the caller
- Believing catching an exception inside the callback still rolls back the transaction
- Thinking an inner participating transaction's rollback-only can be cleared by the outer