Skip to content
Preptima
hardConceptScenarioMidSeniorStaff

The inner service threw, you caught the exception in the caller, and then the commit failed with UnexpectedRollbackException. Why?

The inner method joined the caller's transaction, so its rollback rule marked the one physical transaction rollback-only. Catching the exception hides it from your code but not from the transaction manager, which then refuses to commit. Use REQUIRES_NEW when the inner work must be able to fail alone.

5 min readUpdated 2026-07-29Target archetype: Big Tech, Enterprise Captive, Product Startup
Practice answering out loud

What the interviewer is scoring

  • Does the candidate separate the logical transaction scope from the single physical transaction underneath it
  • Whether the rollback-only marker is named as the mechanism rather than the exception being blamed on Spring
  • That REQUIRES_NEW is described as suspending the outer transaction and taking a second connection, with the consequences that follow
  • Can they state which exceptions cause a rollback by default and how to change that
  • Whether the try and catch are placed relative to the transaction boundary deliberately rather than by habit

Answer

One physical transaction, several logical scopes

Spring's declarative transactions are logical scopes layered over one physical resource transaction. When an outer method annotated @Transactional calls an inner method also annotated @Transactional with the default REQUIRED propagation, no second transaction begins. The inner call joins the existing one: same connection, same database transaction, same eventual single commit. What the inner scope gets is its own entry in the transaction manager's bookkeeping, so that leaving it can be recorded.

That bookkeeping is where your exception went. The advice around the inner method catches the exception on the way out, decides from the rollback rules that this transaction must not commit, and, because it is a participant rather than the owner, cannot roll anything back itself. So it does the only thing available: it sets a rollback-only flag on the shared transaction and rethrows. Your catch block then swallows the exception, the outer method returns normally, and the outer advice asks to commit. The transaction manager sees the flag, refuses, rolls back instead, and reports the discrepancy to you as UnexpectedRollbackException — a deliberate choice to fail loudly rather than let a caller believe work was committed when it was not.

sequenceDiagram
  participant O as Outer method
  participant I as Inner method with REQUIRED
  participant TM as Transaction manager
  O->>TM: begin physical transaction
  O->>I: call
  I->>TM: participate in existing transaction
  I--xTM: throws, so mark rollback-only
  O->>O: catch and continue
  O->>TM: commit requested
  TM--xO: rollback done and UnexpectedRollbackException

The instructive edge is the fourth step. The flag is set on the shared transaction, so by the time your catch block runs the outcome is already decided, and nothing you do afterwards inside that transaction can change it.

Which exceptions set the flag

By default, only unchecked exceptions and Error trigger a rollback. A checked exception propagating out of a transactional method causes a commit, which surprises most people the first time and is deliberate: the framework's assumption is that a checked exception is a modelled outcome the caller is expected to handle, while a runtime exception is a failure. Whether you agree matters less than knowing it, because a business rule that throws a checked exception after a partial write will commit that partial write.

rollbackFor and noRollbackFor on @Transactional change the rule per method, and they take exception types, so rollbackFor = Exception.class makes a method roll back on everything. A codebase that throws checked exceptions from its service layer should set that consistently rather than per method, because the inconsistent version is worse than either default.

What the three propagation modes actually do

REQUIRED joins an existing transaction or starts one if there is none. Because it joins, its failure is the outer transaction's failure, which is the behaviour behind your exception.

REQUIRES_NEW suspends the current transaction and starts an independent one, which means a second connection from the pool held simultaneously with the first, and a commit or rollback of its own. The inner work can now fail without affecting the outer, and this is the correct choice when that independence is the requirement. The costs are real, though: the outer transaction is still open and still holding its locks, so if the inner transaction needs a row the outer one has modified it will block against its own caller until the lock timeout expires. It also doubles connection demand under load, and a pool sized for one connection per request will deadlock when every request wants two.

NESTED takes a savepoint in the current transaction instead, so the inner work can be rolled back to the savepoint while the outer transaction survives, with no second connection. It needs savepoint support all the way down, and it is not universally available: with the JPA transaction manager, nested transactions are not permitted by default and asking for one fails rather than silently degrading. When it does work it is the cheapest way to make one step of a long transaction retryable.

Where the try and catch belong

Once the mechanism is clear, the design rule follows. If you intend to handle a failure and carry on committing, the failing work must not be part of the transaction you intend to commit. Concretely, that means one of three arrangements.

Move the boundary outwards: make the caller non-transactional, and let it call two transactional methods in sequence so that the first one's failure rolls back only itself. This is the cleanest option whenever the two pieces of work do not need to be atomic together, and it is under-used because annotating the outer method feels tidier.

Make the inner scope independent with REQUIRES_NEW, accepting the second connection and the lock ordering that comes with it. Suitable for genuinely separate work such as writing an audit or outbox row that must survive the failure of the main operation.

Or do not let the exception be thrown across a transactional boundary at all. If the inner method's failure is an expected outcome, have it return a result rather than throw, and let the caller decide. Nothing marks the transaction rollback-only, because nothing failed as far as the framework can see. This is often the honest modelling, and it removes the interaction rather than configuring around it.

Two details worth having ready

The transaction manager can be configured not to propagate a participant's failure globally, and to fail early when the flag is set rather than at commit. Both are properties on Spring's abstract transaction manager, and the second is worth knowing about because it converts a confusing exception at commit into an exception at the point the participant failed, which is much easier to debug. Neither is something to reach for as a fix for the design above; they change where you find out, not what happened.

The other detail is the interaction with proxying. Because the advice lives in a proxy, an inner method invoked on this inside the same class gets no advice at all, so the propagation you configured never applies and no rollback-only flag is ever set. That is a different failure with the same symptom of "the annotation did nothing", and knowing which of the two you are looking at is the difference between a five-minute fix and an afternoon.

Likely follow-ups

  • What does REQUIRES_NEW cost you when the outer transaction already holds locks the inner one needs?
  • When is NESTED usable, and what does your persistence setup have to support for it?
  • Why does a checked exception commit by default, and where would you change that?
  • If the inner call only writes an audit record, is a transaction the right mechanism for it at all?

Related questions

springtransactionspropagationrollbacktransaction-manager