How do you apply a reactive transaction programmatically with TransactionalOperator versus declaratively with @Transactional?
answer
- @Transactional on Mono/Flux -> TransactionInterceptor reactive path
- commit on complete, rollback on error signal
- TransactionalOperator.create(tm) then .as(op::transactional)
- self-invocation bypasses proxy; operator avoids it
- must return the publisher or nothing wraps
basics
~10 sDeclaratively, annotate a method returning Mono/Flux with @Transactional and Spring wraps it. Programmatically, build a TransactionalOperator from the ReactiveTransactionManager and apply it to your publisher with operator.transactional(...) or .as(operator::transactional).
solid answer
~40 sTwo styles. Declarative: put @Transactional on a bean method that returns a reactive type; TransactionInterceptor detects the reactive return, opens a transaction via the ReactiveTransactionManager, and commits or rolls back based on whether the returned publisher completes or errors — subject to AOP proxy rules (public method, external call). Programmatic: create a TransactionalOperator with TransactionalOperator.create(reactiveTxManager) (optionally a TransactionDefinition), then wrap a publisher: mono.as(txOperator::transactional) or txOperator.transactional(flux). The operator begins the transaction on subscription, commits when the wrapped publisher completes, and rolls back on error signal. TransactionalOperator gives fine-grained, self-invocation-safe boundaries and easy per-call definitions; @Transactional is terser but bound by proxy limitations. Both ultimately drive getReactiveTransaction/commit/rollback and carry state in the Reactor Context.
code
java · 12 lines// Programmatic boundary, self-invocation-safe, per-call definition
var def = new org.springframework.transaction.support.DefaultTransactionDefinition();
def.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
TransactionalOperator rxtx = TransactionalOperator.create(reactiveTxManager, def);
Mono<Void> tx = accounts.debit("A", 100)
.then(accounts.credit("B", 100))
.then()
.as(rxtx::transactional); // begin on subscribe, commit on complete, rollback on error
// Declarative equivalent:
// @Transactional public Mono<Void> transfer() { return accounts.debit(...).then(accounts.credit(...)).then(); }go deeper
Know both styles exist; annotation on Mono-returning methods.
Explain commit-on-complete/rollback-on-error and the return-the-publisher rule.
Contrast proxy limits vs operator, pass custom TransactionDefinition, discuss self-invocation.
Design boundary granularity, choose per-call definitions, and reason about propagation/read-only in R2DBC.
## Declarative: @Transactional on reactive methods You annotate a Spring-managed bean method that **returns a reactive type** (`Mono`, `Flux`, or any `Publisher`): ```java @Service class TransferService { private final AccountRepository accounts; TransferService(AccountRepository accounts) { this.accounts = accounts; } @Transactional public Mono<Void> transfer(String from, String to, long cents) { return accounts.debit(from, cents) .then(accounts.credit(to, cents)) .then(); } } ``` How it works: Spring's `TransactionInterceptor` (installed by `@EnableTransactionManagement`, auto-configured in Boot) sees the reactive return type and takes the **reactive path** (`ReactiveTransactionSupport`). On subscription it calls `getReactiveTransaction` on the `ReactiveTransactionManager`, then **commits when the returned publisher completes** and **rolls back when it emits an error** — matching rollback rules (`rollbackFor`, unchecked by default). All of this is woven into the Reactor Context so nested repository calls join the same transaction. ### Constraints (AOP proxy) - Must be a **public** method on a **Spring bean**, invoked from **outside** the bean. **Self-invocation** (`this.transfer(...)`) bypasses the proxy → no transaction. - The method **must return** the publisher. A `void` method doing reactive work commits immediately and the actual DB work runs *after* commit. ## Programmatic: TransactionalOperator When you need explicit boundaries, dynamic `TransactionDefinition`s, or to avoid proxy pitfalls (including self-invocation), use **`TransactionalOperator`**: ```java ReactiveTransactionManager tm = new R2dbcTransactionManager(connectionFactory); TransactionalOperator rxtx = TransactionalOperator.create(tm); // or create(tm, definition) Mono<Void> result = accounts.debit(from, cents) .then(accounts.credit(to, cents)) .then() .as(rxtx::transactional); // Mono form // Flux form: Flux<Row> rows = repository.findAll().as(rxtx::transactional); ``` Semantics: on subscription the operator **begins** the transaction; it **commits** when the wrapped publisher **completes normally**, and **rolls back** on any **error signal** (also on cancellation, depending on version). You can pass a custom `TransactionDefinition` (propagation, isolation, read-only, timeout) to `create`. ## Choosing between them - **@Transactional** — least boilerplate, good default for service methods; watch proxy/self-invocation and the void-return trap. - **TransactionalOperator** — precise boundaries, per-invocation definitions, no proxy needed, safe from self-invocation; more verbose. Ideal in library code or where the boundary is narrower than a whole method. ## Common gotchas - **Forgetting `.as(op::transactional)` / not returning the Mono** — the transaction never wraps the work. - **Mixing propagation expectations** — reactive transactions support propagation but nested behavior differs from imperative; verify with your version. - **Blocking calls inside** — any blocking JDBC/JPA won't join the reactive transaction and will stall the event loop. - **Read-only** — set via the `TransactionDefinition` / `@Transactional(readOnly=true)` for optimizations; still meaningful in R2DBC.
- In the reactive @Transactional path, what triggers commit vs rollback?Commit happens when the returned publisher completes normally (onComplete). Rollback happens when it emits an error signal (onError) matching the rollback rules. It is driven at subscription/termination, not when the method returns.
- Why can TransactionalOperator succeed where @Transactional silently does nothing?@Transactional relies on an AOP proxy, so self-invocation or non-bean calls bypass it. TransactionalOperator applies the transaction directly to the publisher with no proxy, so it works regardless of call site.
saying these in an interview costs you the question
- Saying the transaction commits when the method returns the Mono (it commits on subscription completion)
- Believing self-invoked @Transactional still opens a transaction
- Applying @Transactional to a void method doing reactive work
- Thinking TransactionalOperator blocks to begin the transaction