How does the reactive transaction propagate through the pipeline, and what operations will silently NOT participate in the transaction?
answer
- TransactionContext in Reactor subscriber Context
- follows the subscription, not the thread
- JdbcTemplate/JPA use ThreadLocal → excluded
- inner .subscribe() = separate tx
- .block() severs propagation
basics
~20 sThe transaction (its connection) is stored in the Reactor subscriber Context and flows to every operator in the same reactive chain. Anything that leaves that chain — a blocking JDBC call, work on a separate subscription, or code after .block() — does not join the transaction.
solid answer
~50 sReactive Spring keeps the transaction in a `TransactionContext` held in the Reactor **subscriber Context**, which propagates along the subscription of one continuous reactive chain. Every R2DBC/reactive-repository operation composed into that chain reads the context and enlists in the same transaction and connection automatically — regardless of which thread runs each stage. What does **not** participate: (1) blocking data access such as `JdbcTemplate`/JPA — it uses ThreadLocal, not the Reactor Context; (2) any work triggered on a **separate subscription** or a different reactive chain (e.g. a fire-and-forget `subscribe()` inside a `doOnNext`); (3) code after you break out via `.block()`/`.toFuture()`; (4) publishers that don't carry your Context because you replaced it. The rule of thumb: only reactive data access that stays inside the single wrapped pipeline is transactional. Everything else runs outside the transaction and won't roll back.
code
java · 17 lines// WRONG: the blocking JPA save runs outside the reactive transaction
Mono<Void> broken = rxtx.transactional(
r2dbcOrderRepo.save(order)
.doOnNext(o -> jpaAuditRepo.save(new Audit(o))) // ThreadLocal-bound, NOT enlisted
.then());
// WRONG: inner subscribe() starts a separate subscription -> not in the transaction
Mono<Void> alsoBroken = rxtx.transactional(
r2dbcOrderRepo.save(order)
.doOnNext(o -> r2dbcAuditRepo.save(new Audit(o)).subscribe()) // fire-and-forget
.then());
// RIGHT: everything composed into one reactive chain
Mono<Void> ok = rxtx.transactional(
r2dbcOrderRepo.save(order)
.flatMap(o -> r2dbcAuditRepo.save(new Audit(o)))
.then());go deeper
Know the transaction lives in the Reactor Context and flows through the chain.
Identify that blocking JDBC/JPA and .block() fall outside the reactive transaction.
Explain ThreadLocal-vs-Context as the root cause and name inner-subscribe as a silent trap.
Reason about atomicity guarantees across mixed reactive/blocking stores and why a single unbroken subscription is the invariant.
## The propagation mechanism When the operator opens a transaction, the `ReactiveTransactionManager` places a `TransactionContext` (holding the R2DBC `Connection`/transaction resources) into the **Reactor Context**. The Reactor Context is an immutable key/value map that Reactor propagates **upstream along a subscription**. Crucially it is tied to the **subscription**, not to any thread — so a `flatMap` that runs on a different scheduler still sees the same context. Reactive repositories, `DatabaseClient`, and `R2dbcEntityTemplate` look up the `TransactionContext` from the current Reactor Context and reuse its connection, thereby enlisting in the transaction. ## What DOES participate - R2DBC repository calls / `DatabaseClient` / `R2dbcEntityTemplate` composed into the same chain via `flatMap`, `then`, `zip`, `concatMap`, etc. - Nested reactive service methods that return `Mono`/`Flux` you compose in. ## What does NOT participate (the silent traps) 1. **Blocking data access** — `JdbcTemplate`, JPA/Hibernate, plain JDBC. These bind their connection to a **ThreadLocal**, which the reactive transaction never populated. Even wrapping them in `Mono.fromCallable(...).subscribeOn(boundedElastic())` does **not** enlist them; they open their own connection outside the reactive tx. 2. **Separate subscriptions** — calling `.subscribe()` on another publisher inside a `doOnNext`/`doOnSuccess` starts a **new** subscription. Unless you explicitly propagate the context, that inner subscription doesn't inherit your transaction, and it runs (and commits) independently. 3. **Breaking out of the chain** — `.block()`, `.toFuture()`, or handing data to a non-reactive callback severs context propagation; anything after that point is outside the transaction. 4. **Context replacement** — operators like `contextWrite` that overwrite the transaction key, or bridging through APIs that don't preserve Reactor Context, can drop the `TransactionContext`. ## Why this matters Because failures are silent: the code compiles and 'works', but the supposedly-transactional write either used a different connection (no atomicity) or committed on its own (no rollback). In an interview, the strong answer names the ThreadLocal-vs-Context mismatch as the root cause. ## Threading is a non-issue on purpose A common misconception is that you must pin the pipeline to one thread. You must not — the whole point of Context propagation is that thread hops are safe. What you must keep is a **single, unbroken reactive chain** subscribed once. ## Correct pattern Keep every participating operation as a composed reactive step returning `Mono`/`Flux`, wrap the whole thing once with `transactional(...)`, and return it so the framework subscribes. Do not `.subscribe()` internally, do not `.block()`, and do not mix in blocking JDBC.
- If I wrap a blocking JdbcTemplate call in Mono.fromCallable(...).subscribeOn(boundedElastic()), is it now in the reactive transaction?No. It runs on a worker thread and opens its own JDBC connection bound to a ThreadLocal; it never reads the R2DBC TransactionContext, so it is not enlisted and won't roll back with the reactive transaction.
- Do I need to keep the whole pipeline on one thread for the transaction to hold?No. Context propagation is thread-independent by design; thread hops via schedulers are fine. You must keep a single unbroken subscription, not a single thread.
saying these in an interview costs you the question
- Assuming a boundedElastic-wrapped JDBC call joins the reactive transaction
- Calling .subscribe() on inner publishers and expecting them to share the transaction
- Believing the pipeline must stay on one thread to preserve the transaction