DEV Community

Ankit Verma
Ankit Verma

Posted on

Transaction propagation (REQUIRED / REQUIRES_NEW / NESTED ...) (Part 2 of 2)

Picking up between "join" and "start fresh"

Part 1 left you with two propagation modes and a clean way to think about them. REQUIRED joins whatever transaction is already running, so everything shares one all-or-nothing boundary. REQUIRES_NEW ignores the outer transaction, suspends it, and runs a completely separate one that commits or fails on its own.

Those two are opposites. Join everything, or share nothing. But real code sometimes wants something in between: let the inner work fail and be undone on its own, yet still tie its success to the outer transaction's success. Neither mode gives you that. REQUIRED can't undo just the inner part; REQUIRES_NEW commits the inner part too early.

The mode that fills that gap is NESTED, and it works with a database feature called a savepoint. So before we can name NESTED, we have to build a savepoint.

What a savepoint is

Picture a single transaction running along, making changes. A savepoint is a marker you drop partway through it. It says: remember exactly what the world looked like at this spot.

Later, if something goes wrong, you have a new option. Instead of rolling back the whole transaction, you can roll back to the savepoint. That undoes every change made after the marker, keeps every change made before it, and — this is the important part — the transaction is still alive and can carry on.

So a savepoint is a partial undo button inside one transaction. Not the end of the transaction, just a rewind to a point you chose.

Hold that picture, because NESTED is nothing more than Spring dropping a savepoint for you.

NESTED — a savepoint around the inner method

NESTED means: if a transaction is already running, don't start a new one — instead drop a savepoint, run the inner method, and if it fails, roll back only to that savepoint. If no transaction is running yet, NESTED simply behaves like REQUIRED and starts a normal one.

You set it the usual way:

@Transactional(propagation = Propagation.NESTED)
public void writeAuditLog(Order order) {
    auditRepo.save(new AuditEntry(order));
}
Enter fullscreen mode Exit fullscreen mode

Now walk the layered call again. placeOrder opens a transaction. It calls writeAuditLog, whose proxy sees a transaction already open and, because the mode is NESTED, drops a savepoint before running the body:

@Transactional
public void placeOrder(Order order) {
    orderRepo.save(order);
    try {
        writeAuditLog(order);      // NESTED — runs after a savepoint
    } catch (Exception e) {
        // inner failed; only its work is undone
    }
    // outer transaction is still healthy and commits here
}
Enter fullscreen mode Exit fullscreen mode

Here is what makes NESTED different from the REQUIRED trap in Part 1. When writeAuditLog throws, Spring rolls the transaction back to the savepoint — undoing the audit write and nothing else. The transaction is not marked rollback-only. So when you catch the exception and let placeOrder continue, its commit goes through cleanly. The inner failure was contained; the outer work survived.

That is the in-between behaviour we wanted: the inner method can fail and be cleanly undone, without poisoning the whole transaction.

Why NESTED is not REQUIRES_NEW

They sound similar — both let the inner part fail alone — so it is worth being exact about the difference, because it changes what you can rely on.

There is still only one physical transaction. The savepoint lives inside it. That has one consequence that matters most: the inner work is not committed early. It becomes permanent only when the outer transaction finally commits. Roll the outer one back, and the nested work goes with it — savepoint or no savepoint.

Compare that to REQUIRES_NEW, where the inner transaction commits immediately and independently, and survives even if the outer one later rolls back.

So the rule of thumb is about durability on outer failure:

  • Inner work must survive the outer rolling back → REQUIRES_NEW (audit trails, attempt counters).
  • Inner work should be undoable on its own but still tied to the outer commit → NESTED (a sub-step you might retry or skip, but which is meaningless if the whole operation is abandoned).

There is also a plumbing catch worth knowing. Savepoints are a JDBC feature, so NESTED only works on a transaction manager that speaks straight to JDBC — Spring's DataSourceTransactionManager. The JPA manager (JpaTransactionManager), which most Hibernate apps use, does not support NESTED and will throw when you ask for it. It is the least portable of the modes for exactly this reason, which is why you meet it less often than REQUIRED and REQUIRES_NEW.

The four modes you meet less often

That covers the three modes that carry almost all real code. The remaining four are quicker to describe, because each is just a rule about whether a transaction must, may, or must not already exist. Meeting them completes the set of seven.

SUPPORTS — join a transaction if one is running, otherwise run with no transaction at all. It does not insist on either. You reach for it on a read that is fine standing alone but should ride along inside a transaction when one happens to be open.

@Transactional(propagation = Propagation.SUPPORTS)
public Order findOrder(Long id) {
    return orderRepo.findById(id);   // transactional only if a caller already had one
}
Enter fullscreen mode Exit fullscreen mode

NOT_SUPPORTED — the opposite instinct. Suspend any transaction that is running and execute with none, then resume it afterwards. Use it for work that should not sit inside a transaction — a slow report or a long external call you do not want holding a database connection and locks open the whole time.

MANDATORY — run in the caller's transaction, and if there isn't one, throw. It never starts a transaction itself. This is a guard: a method that is only ever meant to be a step inside a larger unit of work, and should refuse to run if someone calls it standalone.

@Transactional(propagation = Propagation.MANDATORY)
public void applyLoyaltyPoints(Order order) {
    // throws IllegalTransactionStateException if no transaction is already open
}
Enter fullscreen mode Exit fullscreen mode

NEVER — the mirror of MANDATORY. Run only if there is no transaction, and throw if one is open. It is a rare, defensive choice for code that must not be allowed to run inside a transaction under any circumstances.

Line them up and the pattern is clear. REQUIRED, REQUIRES_NEW, and NESTED each make a transaction happen in some form. SUPPORTS and NOT_SUPPORTED are relaxed — they take whatever is there. MANDATORY and NEVER are strict guards that throw when the world isn't the way they demand.

When does a transaction actually roll back?

One question is still open, and it surprises people as often as the propagation traps do. A transaction rolls back when an exception escapes the method — but which exceptions? The answer is not "all of them," and the default is easy to get wrong.

Recall from Java that exceptions come in two families. Unchecked exceptions extend RuntimeException (or Error) — the compiler does not force you to declare or catch them. Checked exceptions are everything else — the ones you must declare with throws or handle.

Spring's default rollback rule splits exactly along that line:

  • An unchecked exception (RuntimeException or Error) that escapes the method → rollback.
  • A checked exception that escapes the method → commit anyway.

That second line is the trap. Read it slowly, because it is the opposite of what most people expect:

@Transactional
public void placeOrder(Order order) throws PaymentException {
    orderRepo.save(order);
    paymentGateway.charge(order);   // throws PaymentException (a checked exception)
}
Enter fullscreen mode Exit fullscreen mode

If PaymentException is a checked exception, Spring commits the order anyway. The save sticks even though payment failed and the method exited by throwing. Nothing about "an exception was thrown" saved you here — the kind of exception decided it, and a checked one does not trigger rollback by default.

This default is a historical inheritance from the old EJB world, where the same checked-versus-unchecked convention held. It rarely matches what a modern app wants. Fortunately you override it in one place.

Telling Spring what to roll back on

The override lives on the annotation. rollbackFor adds exception types that should roll back even though they normally wouldn't; noRollbackFor does the reverse.

@Transactional(rollbackFor = PaymentException.class)
public void placeOrder(Order order) throws PaymentException {
    orderRepo.save(order);
    paymentGateway.charge(order);   // now a thrown PaymentException rolls the save back
}
Enter fullscreen mode Exit fullscreen mode

With rollbackFor in place, the checked PaymentException triggers a rollback like you wanted, and the order no longer sticks after a failed payment. The mirror case is rarer but real — a checked exception you throw for control flow, that you specifically do not want to undo the work:

@Transactional(noRollbackFor = OrderAlreadyConfirmedException.class)
public void confirmOrder(Order order) throws OrderAlreadyConfirmedException {
    // this exception signals "nothing to do," so keep whatever was written
}
Enter fullscreen mode Exit fullscreen mode

The habit worth forming: whenever a @Transactional method can throw a checked exception that ought to undo its writes, name it in rollbackFor. Leave it out and the default quietly commits work you meant to discard.

The whole picture, in order

Propagation is a per-method answer to one question — when this method runs, what should happen to the transaction around it? The seven modes are just the seven honest answers. REQUIRED joins one. REQUIRES_NEW suspends the outer and runs a fully independent one. NESTED stays inside the outer transaction but drops a savepoint so the inner step can be undone alone — as long as your transaction manager speaks JDBC. SUPPORTS and NOT_SUPPORTED go along with whatever is there. MANDATORY and NEVER stand guard and throw when the surroundings are wrong.

And sitting underneath all of it is the rollback rule that decides whether a boundary commits or discards: unchecked exceptions roll back, checked ones commit, and rollbackFor is how you fix the mismatch when your checked failure should have undone the work.

Put Part 1 and Part 2 together and transactions stop being a single opaque annotation. They become a boundary you place deliberately, a propagation mode you choose per layer, and a rollback rule you make explicit — three decisions, each yours to make.

Top comments (0)