skip to content

What are the exact rollback rules for TransactionTemplate — which exceptions roll back, and how does setRollbackOnly() differ from throwing?

level: seniorimportance: must knowfreq 50%

answer

  1. Unchecked (RuntimeException/Error) → auto rollback + rethrow
  2. execute catches Throwable → even checked rolls back (UndeclaredThrowableException)
  3. Contrast @Transactional: commits on checked by default
  4. setRollbackOnly = rollback but return normally
  5. Inner rollback-only poisons outer → UnexpectedRollbackException

basics

~10 s

If 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 s

TransactionTemplate 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
java
// 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

for a junior

Know unchecked exceptions roll back and normal return commits.

for a middle

Add setRollbackOnly() as the no-throw rollback, and that Errors roll back too.

for a senior

Articulate the Throwable-catch behavior, the contrast with @Transactional's checked-exception default, and the swallowed-exception-commits pitfall.

for a principal

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

context