How do you make multiple R2DBC operations run in one transaction? Contrast declarative @Transactional with the programmatic TransactionalOperator, and name the transaction manager involved.
answer
- R2dbcTransactionManager = ReactiveTransactionManager
- @Transactional needs Mono/Flux return
- commit on onComplete, rollback on onError
- TransactionalOperator.transactional(mono)
- state in Reactor Context not ThreadLocal
basics
~10 sRegister an R2dbcTransactionManager (a ReactiveTransactionManager). Then either annotate a method returning Mono/Flux with @Transactional, or wrap the pipeline programmatically with TransactionalOperator.transactional(...). Commit happens on completion, rollback on an error signal.
solid answer
~40 sReactive transactions need a `ReactiveTransactionManager`; for R2DBC that's `R2dbcTransactionManager` (Spring Boot auto-configures it from the `ConnectionFactory`). Two styles: **Declarative** — put `@Transactional` on a method whose return type is a `Publisher` (`Mono`/`Flux`); the `TransactionInterceptor` opens a transaction, subscribes to your pipeline, commits on the terminal `onComplete`, and rolls back on `onError`. **Programmatic** — inject `TransactionalOperator` and wrap the pipeline: `operator.transactional(myMono)` or `myFlux.as(operator::transactional)`. Both bind the transactional `Connection` to the **Reactor Context** of the subscription, not to a ThreadLocal, so every operator downstream shares one connection/transaction. Use declarative for straightforward service methods; use `TransactionalOperator` when you need fine-grained scope, dynamic boundaries, or transactions in code without a proxied bean. Crucially, the whole unit of work must live in one reactive chain that is subscribed once.
code
java · 29 lines@Configuration
public class TxConfig {
@Bean
ReactiveTransactionManager txManager(ConnectionFactory cf) {
return new R2dbcTransactionManager(cf); // reactive TM
}
@Bean
TransactionalOperator txOperator(ReactiveTransactionManager tm) {
return TransactionalOperator.create(tm);
}
}
@Service
public class TransferService {
private final AccountRepository repo;
private final TransactionalOperator tx;
// Declarative
@Transactional
public Mono<Void> transferDeclarative(long a, long b, BigDecimal amt) {
return repo.debit(a, amt).then(repo.credit(b, amt)).then();
}
// Programmatic
public Mono<Void> transferProgrammatic(long a, long b, BigDecimal amt) {
return repo.debit(a, amt).then(repo.credit(b, amt)).then()
.as(tx::transactional);
}
}go deeper
Know you need @Transactional (on a Mono/Flux method) or TransactionalOperator, backed by R2dbcTransactionManager.
Explain commit-on-complete/rollback-on-error, the reactive-return-type requirement, and when to pick programmatic over declarative.
Detail Reactor-Context binding of the connection, default rollback-on-any-error, and the failure modes (void return, wrong TM, mid-chain subscribe).
Set guidance on transaction boundaries in reactive services, avoiding blocking calls inside the chain, and consistent TM/operator wiring.
**The problem.** In blocking Spring, a transaction is bound to a **ThreadLocal**; all JDBC work on that thread joins it. Reactive code hops threads freely, so ThreadLocal binding breaks. Reactive transactions instead bind the connection/transaction state to the **Reactor `Context`** that flows with the subscription. **The transaction manager.** You need a `ReactiveTransactionManager`. For R2DBC that is **`R2dbcTransactionManager`**, constructed from the `ConnectionFactory`. Spring Boot auto-configures it when `spring-boot-starter-data-r2dbc` is present, so usually you inject, not declare it. (Do **not** use the blocking `DataSourceTransactionManager` / `PlatformTransactionManager` for reactive flows — it manages ThreadLocal-bound JDBC connections and won't govern your reactive pipeline.) **Declarative — `@Transactional`.** ```java @Service public class TransferService { @Transactional public Mono<Void> transfer(long from, long to, BigDecimal amt) { return accountRepo.debit(from, amt) .then(accountRepo.credit(to, amt)) .then(); } } ``` The `TransactionInterceptor` recognizes the reactive return type, obtains a transaction from `R2dbcTransactionManager`, writes the transactional `Connection` into the subscriber Context, and: - **commits** when the returned publisher emits its terminal completion signal, - **rolls back** when it emits `onError` (any Throwable, by default — note this differs from imperative Spring where only unchecked exceptions roll back). **Requirements for `@Transactional` to actually work reactively:** 1. The method **must return a reactive type** (`Mono`/`Flux`/`Publisher`). On a `void`/plain method the interceptor cannot tie the transaction to a subscription — the transaction is effectively a no-op for reactive work. 2. The whole unit of work must be **inside the returned chain**; you cannot subscribe to a repository elsewhere and expect it to enlist. 3. Called through the Spring proxy (self-invocation bypasses the interceptor, same as blocking). **Programmatic — `TransactionalOperator`.** ```java private final TransactionalOperator operator; // built from R2dbcTransactionManager public Mono<Void> transfer(long from, long to, BigDecimal amt) { Mono<Void> work = accountRepo.debit(from, amt) .then(accountRepo.credit(to, amt)).then(); return operator.transactional(work); // or: work.as(operator::transactional) } ``` `TransactionalOperator.create(reactiveTxManager)` builds it (with optional `DefaultTransactionDefinition` for isolation/propagation/read-only/timeout). `transactional(Mono)` / `transactional(Flux)` / `execute(TransactionCallback)` wrap the pipeline with the same commit-on-complete / rollback-on-error rules. Use it when you want an explicit, possibly narrower transaction boundary, conditional transactions, or transactions in a place that isn't a proxied `@Transactional` bean. **Shared mechanics (both styles):** - A single `Connection` is checked out for the transaction and bound to the Context; all operators in the chain reuse it. - Commit/rollback is driven by the **terminal signal**, so an empty `Mono` (`onComplete` with no value) still commits. - Isolation/propagation/read-only/timeout come from `@Transactional` attributes or the `TransactionDefinition`. **Gotchas:** - Returning `void` from `@Transactional` in a WebFlux service → no reactive transaction. Return `Mono<Void>`. - Mixing a blocking `save()`/JDBC call into the chain won't enlist in the reactive transaction and blocks the event loop. - Wrong TM bean: if a blocking `PlatformTransactionManager` is picked up, reactive `@Transactional` won't behave. Ensure `R2dbcTransactionManager` is the reactive TM. - `.subscribe()` in the middle of the method breaks the boundary — keep one chain, subscribed once by the framework.
- What happens if you annotate a reactive service method with @Transactional but it returns void instead of Mono<Void>?The TransactionInterceptor can't attach the transaction to a subscription, so no reactive transaction is created around the async work — the DB operations run outside any transaction (or the method returns before they even execute). You must return a reactive type so the interceptor can wrap the publisher and commit/rollback on its terminal signal.
- By default, which Throwables trigger a rollback in a reactive @Transactional?Any error signal (onError with any Throwable) rolls back by default in the reactive TransactionInterceptor, whereas classic imperative @Transactional only rolls back on unchecked (RuntimeException/Error) unless you set rollbackFor. Candidates often wrongly apply the imperative checked-exception rule.
saying these in an interview costs you the question
- Using DataSourceTransactionManager/PlatformTransactionManager for reactive R2DBC flows
- Putting @Transactional on a void method and expecting a reactive transaction
- Calling .subscribe() mid-chain, breaking the transaction boundary
- Believing reactive transaction state lives in a ThreadLocal