How do transactions work with reactive R2DBC repositories, and why can't they use thread-local state?
answer
- Threads hop → no ThreadLocal tx
- Tx connection lives in Reactor Context
- R2dbcTransactionManager + @Transactional on Mono/Flux
- Commit on complete, rollback on error signal
- TransactionalOperator for programmatic scope
basics
~10 sReactive transactions are managed by R2dbcTransactionManager and bound to the Reactor subscriber context, not to a thread. Use @Transactional on a method returning Mono/Flux, or TransactionalOperator, so the commit/rollback follows the reactive chain.
solid answer
~40 sIn R2DBC a request hops across threads, so classic thread-local transaction binding (as in JDBC/JPA) cannot work. Spring instead stores the transactional connection in the Reactor Context that flows with the subscriber. You enable it with an R2dbcTransactionManager bean and either @Transactional on a method that returns Mono/Flux (the framework wraps the returned publisher), or a TransactionalOperator for programmatic control (operator.transactional(mono)). The transaction commits when the publisher completes successfully and rolls back on an error signal. Everything in the chain must stay reactive and share the same ConnectionFactory; if you break the chain (e.g. subscribe separately, or block) the context is lost and operations run outside the transaction. @Transactional on a void or non-reactive method won't create a reactive transaction.
code
java · 24 lines@Configuration
class TxConfig {
@Bean
ReactiveTransactionManager txManager(ConnectionFactory cf) {
return new R2dbcTransactionManager(cf);
}
}
@Service
class TransferService {
private final AccountRepository accounts;
TransferService(AccountRepository accounts) { this.accounts = accounts; }
// Declarative: must return a publisher for the reactive tx to apply
@Transactional
public Mono<Void> transfer(long from, long to, BigDecimal amount) {
return accounts.debit(from, amount) // Mono<Void>
.then(accounts.credit(to, amount)) // stays in the chain
.then(); // any error -> rollback, else commit
}
}
// Programmatic alternative:
// operator.transactional(accounts.debit(from, amount).then(accounts.credit(to, amount)));go deeper
Know @Transactional exists for reactive too and needs Mono/Flux.
Can wire R2dbcTransactionManager and annotate a Mono-returning method.
Understands commit-on-complete/rollback-on-error and not breaking the chain.
Explains the Reactor-Context vs ThreadLocal mechanism, sets transaction boundaries and error-propagation conventions, and knows the single-resource/no-reactive-XA limits.
**Why thread-locals fail.** Traditional Spring transaction management (JDBC, JPA) binds the connection/`EntityManager` to a **`ThreadLocal`** via `TransactionSynchronizationManager`. That assumes one thread runs the whole unit of work. In reactive pipelines a single logical operation is scheduled across **many threads** (operators, callbacks, driver I/O), so a `ThreadLocal` set at the start is invisible later. Reactive transactions therefore cannot use thread-local state. **What replaces it.** Reactor provides a **`Context`** — an immutable key/value map that propagates **with the subscription**, upstream from subscriber to source, independent of threads. Spring's reactive transaction infrastructure stores the transactional `Connection` (a `ReactiveTransaction` / connection holder) in that `Context`. Any R2DBC operation in the same reactive chain reads it and joins the transaction. **The pieces.** - **`R2dbcTransactionManager`** — the `ReactiveTransactionManager` implementation for R2DBC (Boot auto-configures one from the `ConnectionFactory`). This is what makes `@Transactional` reactive-aware. - **Declarative**: annotate a method that **returns `Mono`/`Flux`** with `@Transactional`. Spring's reactive interceptor wraps the returned publisher: it opens a transaction on subscribe, **commits on the completion signal**, and **rolls back on an error signal** (by default on any `RuntimeException`/`Error`, configurable via `rollbackFor`). Crucially, `@Transactional` on a method returning `void` or a plain type does **not** create a reactive transaction — the annotation needs a publisher return type to hook the commit/rollback into. - **Programmatic**: inject a **`TransactionalOperator`** (built from the tx manager) and wrap a chain: `operator.transactional(theMono)` or `theFlux.as(operator::transactional)`. Useful for fine-grained scope inside a larger flow. **Semantics and pitfalls.** - **Commit/rollback follow signals, not exceptions thrown imperatively.** If you swallow an error with `onErrorResume` before it reaches the transactional boundary, the transaction still commits. - **Don't break the chain.** If you `subscribe()` to an inner publisher yourself, or block, the tx `Context` doesn't propagate into it, so those operations run **outside** the transaction. Compose with `flatMap`/`then`, and return one publisher. - **Same `ConnectionFactory`.** Repositories, `R2dbcEntityTemplate`, and `DatabaseClient` all participate as long as they share the `ConnectionFactory` the tx manager manages. - **Propagation** works (`REQUIRED`, `REQUIRES_NEW`, etc.) but is implemented over the reactive context; the mental model is the same, the mechanism differs. - **No JTA/XA reactive** across multiple resources in the standard stack — reactive transactions are single-resource. - **Isolation/read-only** hints are honored by the driver where supported. **When to reach for `TransactionalOperator`.** When you need a transaction narrower than a whole method, or must wrap a dynamically composed pipeline where an annotation is awkward.
- You put @Transactional on a method returning void that calls a repository and subscribes internally. Why is there no transaction?A reactive transaction needs a publisher return type so the interceptor can wrap it and tie commit/rollback to the completion/error signal. A void method gives it nothing to wrap, and subscribing internally starts a fresh chain without the transactional Reactor Context, so operations run outside any transaction.
- If you handle an error with onErrorResume before it reaches the @Transactional boundary, does the transaction roll back?No. Rollback is driven by an error signal reaching the transactional boundary. If you convert the error to a normal completion earlier, the boundary sees success and commits. To roll back, let the error propagate to the transactional method's returned publisher.
- Why can't reactive transactions rely on TransactionSynchronizationManager thread-locals?Reactive operators run across many threads, so a connection bound to the initiating thread's ThreadLocal is unavailable to later operators. Reactor's Context propagates with the subscription instead of the thread, so Spring stores the transactional connection there.
saying these in an interview costs you the question
- Putting @Transactional on a void/blocking method and expecting a reactive transaction
- Assuming thread-local (TransactionSynchronizationManager) works in reactive flows
- Swallowing errors before the tx boundary and expecting rollback
- Manually subscribing to inner publishers, losing the transactional context
- Expecting reactive XA/JTA across multiple datasources