How is transaction state propagated in reactive transactions, and why not ThreadLocal?
answer
- ThreadLocal safe only when one thread owns the call
- reactive hops threads -> ThreadLocal lost
- Reactor Context = immutable map on the subscription
- TransactionContext bound into Context, connection too
- forCurrentTransaction() returns a Mono
basics
~20 sReactive transaction state travels in the Reactor Context, an immutable map attached to the subscription. ThreadLocal can't be used because reactive operators run on different threads, so thread-bound state would be lost when execution hops threads.
solid answer
~40 sIn imperative Spring, TransactionSynchronizationManager stores the active transaction, bound connection, and synchronizations in ThreadLocal — safe because a blocking call keeps one thread. Reactive pipelines are different: a Mono/Flux chain can execute across many threads as operators schedule work. A ThreadLocal set when the transaction begins would be invisible after any thread switch. So Spring stores a TransactionContext in the Reactor Context, the immutable key/value map that Reactor propagates downstream with the subscription regardless of thread. R2dbcTransactionManager binds the R2DBC Connection into that context; DatabaseClient and repositories retrieve it from the Context, not from the thread. You can reach the reactive synchronization state via TransactionSynchronizationManager.forCurrentTransaction(), which returns a Mono because the state is Context-scoped, not thread-scoped.
code
java · 8 lines// The state is Context-scoped: you read it INSIDE the reactive chain.
Mono<Boolean> inTransaction =
TransactionSynchronizationManager.forCurrentTransaction()
.map(TransactionSynchronizationManager::isActualTransactionActive)
.defaultIfEmpty(false);
// Contrast: imperative version is a plain static (ThreadLocal) read
// boolean active = TransactionSynchronizationManager.isActualTransactionActive();go deeper
Know state is in Reactor Context, not ThreadLocal.
Explain why threads hop and how Context rides the subscription; know forCurrentTransaction returns Mono.
Detail connection binding via TransactionContext and the break-the-chain pitfall.
Reason about Context propagation across library boundaries (MDC, tracing) and failure modes of losing the Context.
## The problem ThreadLocal solves in blocking code A transaction needs a stable place to keep: *which transaction is active*, *the JDBC connection bound to it*, and *the list of synchronizations* to run at commit/rollback. In imperative Spring this lives in **`TransactionSynchronizationManager`**, which is backed by `ThreadLocal`. This is safe because a blocking method call occupies **one thread** from start to finish — set the ThreadLocal at the start, read it anywhere in the call stack, clear it at the end. ## Why that breaks in reactive code A reactive pipeline is a *deferred* description of work. When subscribed, its operators may run on **different threads**: an event-loop thread, then a thread from `publishOn(Schedulers.boundedElastic())`, then back. There is no single owning thread. A `ThreadLocal` written when the transaction begins would simply be **absent** on the thread that later executes a query — the transaction would look non-existent, or worse, a *different* request's ThreadLocal could leak in on a reused pooled thread. ## The solution: Reactor Context Reactor provides the **`Context`** — an *immutable* key/value map that is attached to the **subscription**, not the thread. It propagates **upstream to downstream implicitly** along the operator chain and is available at every operator no matter which thread runs it. It is the reactive replacement for ThreadLocal. Spring stores a **`TransactionContext`** (holding the current `ReactiveTransaction`, the bound connection, and synchronizations) inside this Reactor Context under a well-known key, managed by `TransactionContextManager`. Because the Context rides with the subscription, the transaction is visible on whatever thread each step happens to run. ## How R2dbcTransactionManager uses it When a reactive transaction starts, `R2dbcTransactionManager` obtains an R2DBC `Connection`, sets autocommit off / begins the transaction, and **binds the connection into the TransactionContext**. `DatabaseClient` and Spring Data R2DBC repositories then call `ConnectionFactoryUtils.getConnection(...)`, which looks up the bound connection **from the Context** (via the reactive `TransactionSynchronizationManager`) rather than opening a fresh one. That is what makes all statements in the pipeline run on the *same* transactional connection. ## Reaching the state yourself ```java Mono<Void> work = TransactionSynchronizationManager.forCurrentTransaction() .flatMap(sync -> { boolean active = sync.isActualTransactionActive(); // register a synchronization, inspect resources, etc. return Mono.empty(); }); ``` Note it returns a **`Mono`** — proof the state is Context-scoped: you can only read it *inside* the reactive chain where the Context exists, not by a plain static getter as in the blocking API. ## Gotchas - **Don't break the chain.** If you subscribe to an inner publisher independently (e.g. `.subscribe()` inside an operator), it starts with an *empty* Context and loses the transaction. Compose with `flatMap`/`then` so the Context flows through. - **Blocking bridges lose it.** Wrapping blocking JDBC that reads ThreadLocal won't see the reactive Context transaction, and vice versa. - **Context propagation to ThreadLocal-based libraries** (logging MDC, Micrometer) needs the `context-propagation` library; the transaction itself, though, is handled internally by Spring.
- Why does TransactionSynchronizationManager.forCurrentTransaction() return a Mono in the reactive API?Because the transaction state lives in the Reactor Context, which only exists inside the subscription. Returning a Mono lets Spring read it from the Context at subscription time instead of from a thread-bound static field.
- What happens if you call .subscribe() on an inner publisher inside a transactional flow?That inner subscription starts with an empty Context, so it loses the transaction and its statements run outside it (or on a new connection). Compose with flatMap/then to keep the Context flowing.
saying these in an interview costs you the question
- Saying reactive transactions use ThreadLocal on the event-loop thread
- Claiming the transaction follows the thread rather than the subscription
- Thinking you can read reactive tx state with a plain static getter
- Assuming a nested independent subscribe() still sees the transaction