Propagation and rollback rules
REQUIRED versus REQUIRES_NEW, why a checked exception commits, and the UnexpectedRollbackException you get for catching an exception from a joined transaction.
After this lesson you can
- Choose a propagation setting and say what happens to the outer transaction
- Predict whether an exception rolls back, including checked exceptions
- Explain UnexpectedRollbackException and how to avoid it
When one transactional method calls another through a proxy, the propagation setting of the called method decides whether it joins the caller's transaction or gets its own.
| Propagation | No transaction active | Transaction active |
|---|---|---|
REQUIRED (default) | starts one | joins it |
REQUIRES_NEW | starts one | suspends it, starts an independent one |
NESTED | starts one | creates a savepoint inside it |
SUPPORTS | runs without | joins it |
NOT_SUPPORTED | runs without | suspends it, runs without |
MANDATORY | throws | joins it |
NEVER | runs without | throws |
REQUIRED means the inner method's work and the outer method's work
commit or roll back together — there is one physical transaction.
REQUIRES_NEW gives the inner method a separate transaction (and a
separate database connection) that commits or rolls back on its own,
which is what you want for an audit record that must survive the
caller's failure. NESTED rolls back to a savepoint without abandoning
the outer transaction; it needs a transaction manager with savepoint
support, such as DataSourceTransactionManager.
Which exceptions roll back
By default the proxy rolls back when the method throws an unchecked
exception — a RuntimeException or an Error, or any subclass — and
commits when it throws a checked exception. The checked exception
still propagates to the caller; the transaction is simply committed
first. Change the rule per method with rollbackFor and
noRollbackFor:
@Transactional(rollbackFor = Exception.class) // checked exceptions roll back too
public void importFile(Path p) throws IOException { ... }
import java.io.IOException;import java.util.*; public class Solution { static boolean rollsBackByDefault(Throwable t) { return t instanceof RuntimeException || t instanceof Error; } static boolean rollsBack(Throwable t, List<Class<? extends Throwable>> rollbackFor) { for (Class<? extends Throwable> c : rollbackFor) if (c.isInstance(t)) return true; return rollsBackByDefault(t); } public static List<String> run() { List<Throwable> thrown = List.of( new IllegalStateException("bad state"), new IOException("disk full"), new StackOverflowError(), new Exception("checked")); List<String> out = new ArrayList<>(); for (Throwable t : thrown) { out.add(t.getClass().getSimpleName() + ": default=" + (rollsBackByDefault(t) ? "ROLLBACK" : "COMMIT") + ", rollbackFor=Exception=" + (rollsBack(t, List.of(Exception.class)) ? "ROLLBACK" : "COMMIT")); } return out; }}If the method catches the exception itself and returns normally, the proxy never sees it and commits.
UnexpectedRollbackException
With REQUIRED, the inner method has no transaction of its own to roll
back. When it throws an unchecked exception, its proxy marks the
shared transaction rollback-only. If the outer method catches
that exception and carries on, it returns normally and its proxy tries
to commit — but the transaction is rollback-only. Spring rolls back and
throws UnexpectedRollbackException to the outer method's caller, so
the "handled" failure turns into an error one level higher.
import java.util.*; public class Solution { static final class Tx { boolean rollbackOnly; } static final List<String> log = new ArrayList<>(); static Tx current; // What the proxy does around a REQUIRED method. static void required(String name, Runnable body) { boolean outermost = current == null; if (outermost) { current = new Tx(); log.add(name + ": begin"); } else log.add(name + ": join"); try { body.run(); } catch (RuntimeException e) { if (outermost) { log.add(name + ": rollback"); current = null; } else { current.rollbackOnly = true; log.add(name + ": mark rollback-only"); } throw e; } if (outermost) { Tx tx = current; current = null; if (tx.rollbackOnly) { log.add(name + ": rollback"); throw new IllegalStateException("UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only"); } log.add(name + ": commit"); } } public static List<String> run() { try { required("placeOrder", () -> { try { required("reserveStock", () -> { throw new IllegalArgumentException("out of stock"); }); } catch (IllegalArgumentException e) { log.add("placeOrder: caught '" + e.getMessage() + "', carrying on"); } }); log.add("caller: success"); } catch (IllegalStateException e) { log.add("caller got " + e.getMessage()); } return log; }}If the outer method genuinely should continue after the inner failure,
the inner work needs its own transaction (REQUIRES_NEW), or the
failure must be prevented or signalled without an unchecked exception
crossing the inner proxy.
Common mistakes
- Expecting
throws IOExceptionfrom a@Transactionalmethod to roll back: by default it commits, as the first example shows. - Catching an exception from a joined
REQUIREDcall and expecting the outer transaction to commit. - Using
REQUIRES_NEWinside a loop or under load: each call holds a second connection while the first is suspended, and the pool can run dry.
What interviewers ask
- Difference between
REQUIREDandREQUIRES_NEW? — join versus an independent, separately committed transaction; the outer one is suspended meanwhile. - Does
@Transactionalroll back on a checked exception? — no, only on unchecked exceptions and errors unlessrollbackForsays so. - When do you get
UnexpectedRollbackException? — an inner joined call marked the transaction rollback-only and the outer method swallowed the exception and tried to commit.
Exercise · Pro
Outer, inner, and what the caller sees
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade