skip to content

You're setting a project-wide policy for transaction rollback rules. What trade-offs, pitfalls, and interactions with propagation would you weigh?

level: principalimportance: should knowfreq 30%

answer

  1. rollbackFor=Exception.class as house policy
  2. Rules only fire at proxy boundary
  3. Self-invocation & swallow bypass rules
  4. Inner rollback -> rollback-only -> UnexpectedRollbackException
  5. REQUIRES_NEW / noRollbackFor for must-persist sub-work

basics

~20 s

Decide whether all exceptions should roll back (rollbackFor = Exception.class) or keep the default. Watch for: swallowed exceptions bypassing rules, self-invocation not being proxied, and inner rollbacks marking the whole physical transaction rollback-only, causing UnexpectedRollbackException in outer methods.

solid answer

~50 s

The core policy question is whether to keep Spring's default (commit on checked) or standardize on rollbackFor = Exception.class so every propagating exception is atomic — I usually prefer the latter for predictability, since committing on checked exceptions surprises most teams. Beyond the rule itself I weigh three interactions: (1) rules only fire on exceptions that escape the transactional proxy — catch-and-swallow or self-invocation (calling the annotated method from within the same bean) silently bypasses them; (2) with PROPAGATION_REQUIRED, an inner @Transactional that rolls back marks the shared physical transaction rollback-only, so even if the outer method catches the exception, commit fails with UnexpectedRollbackException; (3) noRollbackFor is a sharp tool that lets partial work persist — fine for audit/idempotency rows, dangerous if misused. I'd document the policy, prefer typed rules, and use REQUIRES_NEW deliberately where a sub-operation must commit independently.

code

java · 24 lines
java
// Centralized house policy
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Transactional(rollbackFor = Exception.class)
public @interface AtomicTx {}

@Service
class OrderService {
    @Autowired AuditService audit;

    @AtomicTx
    public void place() {
        try {
            inner();                 // REQUIRED: joins THIS transaction
        } catch (RuntimeException e) {
            // inner() rolled back -> tx is now rollback-only.
            // Swallowing here does NOT save us:
            // commit will throw UnexpectedRollbackException.
        }
    }

    @Transactional // PROPAGATION_REQUIRED (default)
    void inner() { throw new IllegalStateException(); }
}

go deeper

for a junior

Not expected — this is architecture-level policy and propagation interplay.

for a middle

Should recognize self-invocation and swallowed-exception pitfalls.

for a senior

Can explain rollback-only marking and UnexpectedRollbackException mechanics.

for a principal

Owns the org-wide policy: baseline rule, composed annotation, propagation mapping, and deliberate noRollbackFor/REQUIRES_NEW usage with documented rationale.

## Designing a rollback-rule policy at scale ### 1. The baseline choice: default vs. rollbackFor = Exception.class - **Default** (commit on checked): matches EJB legacy, but many teams find 'a thrown checked exception committed my half-written work' astonishing. Bugs hide here. - **`rollbackFor = Exception.class`** (often applied via a meta-annotation or a shared `@Transactional` composed annotation): every propagating exception rolls back. Predictable and usually what people expect. This is a common enterprise convention. - You can codify it with a **custom composed annotation**: ```java @Target(METHOD) @Retention(RUNTIME) @Transactional(rollbackFor = Exception.class) public @interface DataTx {} ``` ### 2. Rules only apply at the proxy boundary Spring's declarative transactions are **proxy-based (AOP)**. Consequences: - **Catch-and-swallow**: if the method catches its own exception and returns normally, no rule is evaluated — it commits. To force rollback from inside, call `TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()`. - **Self-invocation**: calling an annotated method via `this.method()` from another method in the *same* bean does **not** go through the proxy, so `@Transactional` (and thus its rollback rules) is ignored entirely. Fix by refactoring to another bean or self-injection. ### 3. Propagation interaction — the rollback-only trap With the default `PROPAGATION_REQUIRED`, a nested `@Transactional` call **joins** the existing physical transaction. If the inner method's exception triggers rollback, Spring marks the **shared** transaction **rollback-only**. Now even if the **outer** method catches that exception and tries to continue, the eventual commit throws **`UnexpectedRollbackException`** ('Transaction silently rolled back because it has been marked as rollback-only'). - This is a frequent production surprise: 'I caught the exception, why did the whole thing blow up?' - Remedies: use **`PROPAGATION_REQUIRES_NEW`** for a sub-operation that must succeed/fail independently (its own physical transaction), or **`PROPAGATION_NESTED`** (JDBC savepoints) where supported, or don't catch-and-continue across a rollback-marked boundary. ### 4. noRollbackFor as a deliberate escape hatch `noRollbackFor` lets partial work persist despite an exception. Legitimate uses: writing an **audit/outbox row** or an **idempotency marker** that must survive even when the business op fails; treating a domain 'exception' as a normal signal. Danger: it can leave the DB in a partially-updated state if applied carelessly — reserve it for well-understood cases and document intent. ### 5. Consistency and observability - Prefer **typed** `rollbackFor`/`noRollbackFor` over `*ClassName` strings (substring matching over-matches). - Consider a shared meta-annotation so the policy is centralized and greppable. - Log rollback decisions in tricky flows; enable Spring's transaction debug logging when diagnosing. ### 6. Reactive/other tx managers The rule semantics (`RuleBasedTransactionAttribute`) are transaction-manager-agnostic and apply equally to JPA, JDBC, and reactive `@Transactional` — the commit/rollback decision logic is the same; only the resource binding differs. ### Summary decision framework 1. Pick a baseline (I lean `rollbackFor = Exception.class`). 2. Encode it once (composed annotation). 3. Educate on proxy limits (swallow/self-invocation). 4. Map propagation deliberately; expect `UnexpectedRollbackException` when catching across REQUIRED boundaries. 5. Use `noRollbackFor` / `REQUIRES_NEW` surgically for must-persist sub-work.

  • Why does catching the exception in the outer method still fail with UnexpectedRollbackException?
    Under PROPAGATION_REQUIRED the inner method shares the outer physical transaction. Its rollback marks the whole transaction rollback-only (a global flag). When the outer method completes and the transaction manager attempts commit, it sees the rollback-only marker and throws UnexpectedRollbackException regardless of the caught exception.
  • How do you let a sub-operation commit independently of the caller's rollback?
    Use @Transactional(propagation = Propagation.REQUIRES_NEW) on the sub-operation so it runs in its own suspended physical transaction. Its commit/rollback is independent, and its rollback won't mark the outer transaction rollback-only.
  • Why can self-invocation silently ignore rollback rules?
    Declarative transactions are proxy-based. Calling an annotated method via this.method() bypasses the proxy, so the transaction advice — and its rollback rules — never runs. Move the method to another bean or use self-injection so the call goes through the proxy.

saying these in an interview costs you the question

  • Believing catching the exception in the outer method always prevents UnexpectedRollbackException
  • Assuming @Transactional works on self-invoked (this.) calls
  • Treating noRollbackFor as safe to apply broadly
  • Thinking rollbackFor changes behavior for swallowed exceptions
  • Assuming REQUIRED nested calls get independent commit/rollback

context