skip to content

How does the reactive transaction propagate through the pipeline, and what operations will silently NOT participate in the transaction?

level: seniorimportance: should knowfreq 45%

answer

  1. TransactionContext in Reactor subscriber Context
  2. follows the subscription, not the thread
  3. JdbcTemplate/JPA use ThreadLocal → excluded
  4. inner .subscribe() = separate tx
  5. .block() severs propagation

basics

~20 s

The 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 s

Reactive 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
java
// 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

for a junior

Know the transaction lives in the Reactor Context and flows through the chain.

for a middle

Identify that blocking JDBC/JPA and .block() fall outside the reactive transaction.

for a senior

Explain ThreadLocal-vs-Context as the root cause and name inner-subscribe as a silent trap.

for a principal

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

context