You have a batch loop that processes many items, catches per-item failures, and continues — but it throws UnexpectedRollbackException at the end. How do you fix it?
answer
- isolate each item -> REQUIRES_NEW
- loop non-transactional avoids poison
- NESTED = savepoints, not for JPA tx manager
- REQUIRES_NEW suspends + new connection
- extract to separate bean (proxy!)
basics
~20 sGive each item its own transaction. Move the per-item work into a separate bean method annotated @Transactional(propagation = REQUIRES_NEW) so one item's failure rolls back only that item, not the whole batch, and doesn't mark a shared transaction rollback-only.
solid answer
~40 sThe bug: the loop and per-item work run in one shared transaction. When an item's inner @Transactional method fails, it marks that shared transaction rollback-only; your catch swallows the exception and continues, and the final commit throws UnexpectedRollbackException. Fix by isolating each item in its own physical transaction: extract per-item processing into a separate Spring bean (so the proxy applies) annotated @Transactional(propagation = Propagation.REQUIRES_NEW). Now each item commits or rolls back independently, and a failure taints only that item. Wrap the call in try/catch to record failures and keep going. Alternatively, run the loop entirely outside any transaction and open a fresh transaction per item, or use NESTED with savepoints if the DB/driver supports it. Watch for self-invocation: calling the REQUIRES_NEW method via this. bypasses the proxy and defeats the fix.
code
java · 26 lines@Service
class BatchService {
private final ItemProcessor processor; // separate bean -> proxy applies
BatchService(ItemProcessor processor) { this.processor = processor; }
// No @Transactional here: loop runs outside any shared transaction
public BatchResult processAll(List<Item> items) {
var failures = new ArrayList<Item>();
for (Item i : items) {
try {
processor.processOne(i); // own physical tx
} catch (RuntimeException e) {
failures.add(i); // safe now: no shared tx to poison
}
}
return new BatchResult(failures);
}
}
@Service
class ItemProcessor {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processOne(Item i) {
// commits or rolls back independently per item
}
}go deeper
Recognize that catching per-item and continuing doesn't work inside one transaction.
Apply REQUIRES_NEW on an extracted bean method to isolate each item.
Weigh REQUIRES_NEW vs non-transactional-loop vs NESTED and account for the proxy and connection-pool implications.
Decide batch atomicity semantics and pool sizing as an architectural choice, and encode the correct failure/observability contract.
## The failing pattern ```java @Transactional public void processAll(List<Item> items) { for (Item i : items) { try { processOne(i); // @Transactional, REQUIRED -> participates } catch (Exception e) { failures.add(i); // swallow, continue } } } ``` Everything runs in one physical transaction. The first `processOne` that throws marks the shared transaction rollback-only. Subsequent iterations still 'succeed' logically, but the transaction is already doomed. At method exit Spring attempts commit, sees rollback-only, and throws `UnexpectedRollbackException`. Every item's work is discarded — the opposite of the resilience the try/catch intended. ## Fix 1 — REQUIRES_NEW per item (most common) Extract per-item work to a **separate bean** so Spring's proxy intercepts the call, and annotate with `REQUIRES_NEW`: ```java @Service class ItemProcessor { @Transactional(propagation = Propagation.REQUIRES_NEW) public void processOne(Item i) { /* ... */ } } ``` `REQUIRES_NEW` **suspends** any current transaction and starts a brand-new independent physical transaction for that item. On failure, only that inner transaction rolls back; nothing is marked rollback-only on the outer one. The outer method (or a non-transactional loop) can commit/continue. Caveat: REQUIRES_NEW uses an additional connection while the outer is suspended; a large batch can exhaust the connection pool if the outer transaction is held open. Prefer making the **loop non-transactional** and letting each `processOne` own the only transaction. ## Fix 2 — loop outside a transaction Remove `@Transactional` from `processAll`; keep it on `processOne`. Each item gets its own transaction (REQUIRED is fine now, since there's no outer transaction to poison). Cleaner connection usage. ## Fix 3 — NESTED with savepoints `Propagation.NESTED` creates a JDBC **savepoint** per item inside the outer transaction. An item failure rolls back to its savepoint without dooming the outer transaction. Requires a `DataSourceTransactionManager` with a driver that supports savepoints (JPA/`JpaTransactionManager` does **not** support NESTED). All items still commit together at the end, so it's one atomic batch with per-item undo — different semantics from REQUIRES_NEW (independent commits). ## Fix 4 — don't swallow If the batch should be all-or-nothing, the correct fix may be to **not catch** the exception at all and let the whole batch roll back. UnexpectedRollbackException often reveals that swallowing was semantically wrong. ## The self-invocation trap `@Transactional` works via an AOP proxy. Calling `this.processOne(i)` from within the same class bypasses the proxy, so the `REQUIRES_NEW` never takes effect and you're back to one shared transaction. The extracted method must live in a **different injected bean** (or you must obtain the proxy via `AopContext.currentProxy()` / self-injection). ## Choosing between them - Independent per-item commit, partial success acceptable → **REQUIRES_NEW** (or non-transactional loop + per-item tx). - Atomic batch but tolerate individual bad rows via savepoints → **NESTED**. - Batch must be all-or-nothing → **don't swallow**; let it roll back. ## Verifying the fix Assert that after a mid-batch failure the earlier items are persisted (or, for the atomic option, that none are) and that no `UnexpectedRollbackException` escapes.
- Why must processOne live in a different bean rather than being a private method in the same class?@Transactional is applied by an AOP proxy. A same-class call (this.processOne) bypasses the proxy, so REQUIRES_NEW never activates and you fall back to one shared transaction.
- What resource risk does REQUIRES_NEW inside an outer transaction introduce for large batches?The outer transaction is suspended but still holds its connection while each REQUIRES_NEW borrows another. Deep or concurrent nesting can exhaust the connection pool; making the loop non-transactional avoids holding two connections at once.
- Would NESTED work with JPA?No. JpaTransactionManager does not support NESTED savepoints; you'd need DataSourceTransactionManager with a savepoint-capable driver. With JPA, use REQUIRES_NEW or restructure.
saying these in an interview costs you the question
- Keeping @Transactional on the loop and just adding a try/catch — that's exactly what fails.
- Calling the REQUIRES_NEW method via this.method() and expecting a new transaction.
- Assuming NESTED works with the JPA transaction manager.
- Wrapping the failing inner call in try/catch as the fix without changing propagation or boundary.