intermediate~2h

Programmatic, Reactive & Nested Transactions

Closing out Part 4: when you'd reach for imperative transaction control instead of the annotation, and what happens to all of this once your stack is reactive.

Declarative (@Transactional)Programmatic (TransactionTemplate)
StyleAnnotation-driven, AOP proxy handles everything (Chapter 10)Explicit, imperative code you write yourself
GranularityWhole methodAny arbitrary block of code, even part of a method
Best forThe vast majority of everyday cases — simpler, less codeConditional transaction boundaries decided at runtime, or transactions inside a loop with per-iteration commits

◆ The problem

Batch-processing 10,000 records inside one giant @Transactional method holds locks and an open transaction for the entire batch's duration — a huge blast radius if it fails at record 9,999, and a long-held transaction that hurts concurrency for everyone else (Chapter 07, Chapter 21). Sometimes you want fine-grained, per-chunk transaction boundaries decided by your own loop logic, not one boundary around an entire method.

@Service public class BatchImportService { private final TransactionTemplate transactionTemplate; public BatchImportService(PlatformTransactionManager txManager) { this.transactionTemplate = new TransactionTemplate(txManager); this.transactionTemplate.setTimeout(10); // per-chunk, not per-batch } public void importRecords(List<Record> records) { List<List<Record>> chunks = Lists.partition(records, 500); for (List<Record> chunk : chunks) { transactionTemplate.execute(status -> { try { chunk.forEach(recordRepository::save); return null; } catch (Exception e) { status.setRollbackOnly(); // explicit, programmatic rollback — this chunk only log.error("chunk failed, continuing with next chunk", e); return null; } }); // each chunk commits (or rolls back) independently — a failure in chunk 5 of 20 // doesn't touch chunks 1-4, already safely committed } } }

◆ Under the hood

status.setRollbackOnly() is the programmatic equivalent of throwing an exception inside a @Transactional method — it marks the current transaction to roll back at the end of the execute() block, without actually throwing and unwinding the stack, giving you the chance to log, clean up, or continue processing other independent chunks in the same method.

💻 Code example

@Service public class BatchImportService { private final TransactionTemplate transactionTemplate; public BatchImportService(PlatformTransactionManager txManager) { this.transactionTemplate = new TransactionTemplate(txManager); this.transactionTemplate.setTimeout(10); // per-chunk, not per-batch } public void importRecords(List<Record> records) { List<List<Record>> chunks = Lists.partition(records, 500); for (List<Record> chunk : chunks) { transactionTemplate.execute(status -> { try { chunk.forEach(recordRepository::save); return null; } catch (Exception e) { status.setRollbackOnly(); // explicit, programmatic rollback — this chunk only log.error("chunk failed, continuing with next chunk", e); return null; } }); // each chunk commits (or rolls back) independently — a failure in chunk 5 of 20 // doesn't touch chunks 1-4, already safely committed } } }

◆ The problem

Spring's classic PlatformTransactionManager / @Transactional machinery relies on thread-local storage to track "which transaction is the current thread inside" — but a reactive (WebFlux/R2DBC) application doesn't guarantee your logical operation stays on one thread at all; execution can hop across threads as it moves through a reactive pipeline, breaking thread-local-based transaction tracking entirely.

@Service public class ReactiveWalletService { private final TransactionalOperator transactionalOperator; private final WalletRepository walletRepository; // R2DBC-backed, reactive public Mono<Void> transfer(Long fromId, Long toId, BigDecimal amount) { Mono<Void> work = walletRepository.debit(fromId, amount) .then(walletRepository.credit(toId, amount)); return work.as(transactionalOperator::transactional); // context-propagated, not thread-local } }

◆ Under the hood

Reactive transaction management (via ReactiveTransactionManager and TransactionalOperator) solves the thread-hopping problem by propagating transaction context through the reactive stream's own Context mechanism, rather than thread-local storage — the transaction "travels with" the reactive pipeline itself across whatever threads actually execute each stage, instead of being pinned to a specific thread.

▲ Common mistake

Mixing classic blocking JDBC repositories with a reactive controller/service layer defeats the entire purpose of going reactive — a blocking JDBC call inside a reactive pipeline blocks the shared event-loop thread, which can stall every other concurrent request being processed by that thread, not just the one making the blocking call. Reactive transactions require a genuinely reactive driver (R2DBC), not JDBC wrapped in a Mono.

💻 Code example

@Service public class ReactiveWalletService { private final TransactionalOperator transactionalOperator; private final WalletRepository walletRepository; // R2DBC-backed, reactive public Mono<Void> transfer(Long fromId, Long toId, BigDecimal amount) { Mono<Void> work = walletRepository.debit(fromId, amount) .then(walletRepository.credit(toId, amount)); return work.as(transactionalOperator::transactional); // context-propagated, not thread-local } }

Want a visual for this concept?

Generate a diagram tailored to “Programmatic, Reactive & Nested Transactions” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to Persistence Context & Dirty Checking← Back to all Transaction Mastery chapters