When one transactional method calls another
You have probably written a method like this and moved on:
@Transactional
public void placeOrder(Order order) {
orderRepo.save(order);
paymentGateway.charge(order);
}
That @Transactional tells Spring one thing: run everything in this method as a single unit of work against the database. Either both writes stick, or neither does.
That is simple enough when the method stands alone. But real code is layered. placeOrder calls a service, which calls another service — and several of those methods also carry @Transactional. So a question appears the moment your code has more than one layer: when a transactional method calls another transactional method, is that one transaction, or two?
The answer is not decided for you. You choose it, per method, with a setting called propagation. This article is about what that choice means, the two modes that carry almost all real use, and a trap that can silently switch the whole thing off.
What "a transaction" actually is here
Before propagation can make sense, we need to be precise about the thing being propagated.
A transaction is a boundary drawn around a group of database changes. Inside the boundary, the changes are provisional — pencilled in, not yet permanent. At the end, one of two things happens. You commit, and every change inside becomes permanent together. Or you roll back, and every change inside is thrown away together.
All-or-nothing is the entire point. No half-finished state — an order saved but never paid for — ever reaches the database.
How Spring opens one for you — the proxy
Notice that you never wrote code to open, commit, or roll back that transaction. Spring did it around your method. Here is how, because the "how" is where the traps live.
When a bean has a @Transactional method, Spring does not hand your object to the rest of the app directly. It wraps your object in a proxy — a stand-in object with the same method signatures. Everyone who calls your bean actually holds the proxy, not your real object.
The proxy's job is to run extra work before and after your real method:
// conceptually, the proxy wraps your call like this:
Transaction tx = txManager.begin(); // before your method runs
try {
realOrderService.placeOrder(order); // your actual code
txManager.commit(tx); // after — if nothing threw
} catch (RuntimeException e) {
txManager.rollback(tx); // after — if it threw
throw e;
}
Read the wrapper, not the details. Two facts matter later. First, the transaction is opened by the proxy, never by your own code. Second, it is opened only when a call arrives through the proxy. Hold on to both.
Propagation: join, or start fresh?
Now the layered call. placeOrder is transactional and calls writeAuditLog, which is also transactional.
The proxy around writeAuditLog is about to run. It checks a simple thing: is a transaction already open on this thread? Yes — placeOrder opened one a moment ago. So what should this inner proxy do? Join the transaction already running, or push it aside and start its own?
That decision is propagation. You set it on the annotation:
@Transactional(propagation = Propagation.REQUIRED)
public void writeAuditLog(Order order) { ... }
There are seven modes in total. Two of them carry almost every real case, so we build those fully here, then meet the trap that can quietly disable all seven. The remaining modes — and savepoints, a middle ground between the two — are Part 2.
REQUIRED — the default, and what "join" costs you
REQUIRED means: if a transaction is already running, join it; if not, start a new one. It is the default. Every @Transactional you write without naming a mode behaves this way.
So in our example, both methods are REQUIRED:
@Transactional // REQUIRED by default
public void placeOrder(Order order) {
orderRepo.save(order);
writeAuditLog(order); // joins the same transaction
}
Joining means one physical transaction shared by both methods. Both writes land inside the same boundary, and a single commit at the very end — fired by the outermost method — makes them permanent together.
That shared fate is usually exactly what you want. If writeAuditLog succeeds but placeOrder fails afterwards, the audit write rolls back too. You never keep a log entry for an order that never happened.
But joining has a sharp edge, and it surprises people constantly.
The rollback-only trap
Here is the code that trips everyone up. The inner method throws, and you catch the exception so the outer method can carry on:
@Transactional
public void placeOrder(Order order) {
orderRepo.save(order);
try {
writeAuditLog(order); // inner REQUIRED — it throws
} catch (Exception e) {
// swallow it — auditing is not critical, keep going
}
// proxy now tries to commit here...
}
You caught the exception. You expect the order to commit. Instead you get this:
```plain text
UnexpectedRollbackException: Transaction silently rolled back
because it has been marked rollback-only
Why? Because the two methods share **one** transaction. When `writeAuditLog` throws, its proxy wants to roll back — but it cannot. It did not open the transaction, so it is not allowed to end it. The only thing it can do is set a flag on the shared transaction: **rollback-only**. Meaning: this transaction may no longer commit, no matter what happens next.
Your `catch` block swallowed the exception, but it could not unset that flag. When the outer proxy reaches its commit, the transaction manager sees rollback-only and refuses. The commit becomes a forced rollback, and you get the exception above.
The lesson is blunt: inside one shared transaction, a failure anywhere poisons the whole thing — even a failure you caught. If you genuinely need the inner work to fail on its own without dooming the order, joining is the wrong choice. You need a separate transaction. That is the next mode.
## REQUIRES_NEW — a genuinely separate transaction
**REQUIRES_NEW** means: always start a new, independent transaction. If one is already running, **suspend** it first — pause it, fully intact, changing nothing — run the new transaction to its own commit or rollback, then resume the outer one.
```java
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void writeAuditLog(Order order) {
auditRepo.save(new AuditEntry(order));
}
Now there are two physical transactions, and they are strangers to each other. The audit log commits on its own, immediately, in its own boundary. Two consequences fall out of that, and both are the reason you would reach for this mode.
First, survival. If placeOrder fails and rolls back afterwards, the audit row was already committed in its own transaction — so it stays. The log records the attempt even though the order was undone.
Second, isolation of failure. If writeAuditLog throws and you catch it, only its own transaction rolled back. The outer transaction was suspended and untouched, so there is no rollback-only flag on it. The order commits normally. The trap from the last section simply cannot happen across a REQUIRES_NEW boundary, because the two were never the same transaction.
That independence is the whole use case: work that must land, or fail, regardless of what the surrounding transaction does — audit trails, attempt counters, "we tried to notify the user" records.
It has one real cost, worth knowing before you sprinkle it everywhere. Suspending the outer transaction does not release its database connection — that connection stays open and idle while the new transaction borrows a second one. So for a moment you hold two connections for one request. Under load, that can drain the connection pool faster than you expect. Use REQUIRES_NEW deliberately, where the independence earns its keep.
The trap that silently disables all of this: self-invocation
Everything above rests on one assumption: the call reaches the inner method through its proxy. Here is the everyday way it quietly does not.
@Service
public class OrderService {
@Transactional
public void placeOrder(Order order) {
writeAuditLog(order); // a plain internal call
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void writeAuditLog(Order order) {
auditRepo.save(new AuditEntry(order));
}
}
You asked for REQUIRES_NEW on writeAuditLog. You will not get it. The audit write runs inside placeOrder's transaction, exactly as if the annotation were not there.
The reason is the proxy again. placeOrder calls writeAuditLog(order) as a bare method call, which is really this.writeAuditLog(order). That call goes straight to the real object — this — and never leaves it. It never passes through the proxy. And the proxy is the only thing that reads @Transactional and acts on propagation. Skip the proxy, and you skip propagation entirely.
The fix is to make the call cross a proxy boundary. The cleanest way is usually to move the second method onto a different bean and inject it:
@Transactional
public void placeOrder(Order order) {
orderRepo.save(order);
auditService.writeAuditLog(order); // a real proxy is in between
}
Now the call travels through auditService's proxy, that proxy reads REQUIRES_NEW, and you finally get the separate transaction you asked for. The rule to carry away: propagation only ever applies to a call that enters a bean from the outside, never to a method a bean calls on itself.
Where this leaves us
Three things are worth keeping. REQUIRED joins an existing transaction into one shared boundary — convenient, but a failure anywhere marks the whole thing rollback-only, even one you catch. REQUIRES_NEW carves out a truly independent transaction that commits or fails on its own, at the price of a second connection held open. And none of it takes effect on a plain internal call, because propagation lives in the proxy your own method never touches.
Part 2 picks up the middle ground between joining and full independence — NESTED and savepoints — then walks the four remaining modes, and pins down exactly which exceptions actually trigger a rollback.
Continued in Part 2.
Top comments (0)