TransactionalOperator can bind multiple R2DBC publishers to one reactive transaction and roll them back when the composed pipeline fails.
Spring R2DBC reactive transaction: roll back the event marker with a rejected debit
Compose both writes before applying the operator
The fixture inserts EVT-47 into applied_reservation, then executes a conditional stock debit in the same reactive chain. TransactionalOperator wraps the chain. On success, the marker exists and SKU-47 moves from 19 to 12 with version 4. If a 20-unit debit affects zero rows, the pipeline converts that result into an error; the marker rolls back and stock stays at 19. The consumer ledger explains why the marker and mutation must share a commit.
This uses R2dbcTransactionManager and one R2DBC ConnectionFactory. A JDBC TransactionTemplate in a different datasource does not automatically join the reactive transaction. Reactor Context carries the transaction association through the subscribed pipeline; splitting work into an unmanaged subscribe call can lose that boundary. The JDBC transaction lesson is a separate model.
Specify the failure policy
An error signal triggers rollback here. A caught exception converted to a normal value can instead lead to commit. The method therefore turns a zero-row update into Mono.error rather than swallowing it. Cancellation, timeout, and retry semantics need tests against the selected driver and production database; this embedded fixture checks two sequential outcomes only.
Checked excerpt
return database.sql("insert into applied_reservation(event_id) values (:eventId)")
.bind("eventId", eventId).fetch().rowsUpdated()
.then(database.sql(
"update stock_balance set available = available - :units, version = version + 1 "
+ "where sku = :sku and tenant = :tenant "
+ "and version = :expectedVersion and available >= :units")
.bind("units", units).bind("sku", sku).bind("tenant", tenant)
.bind("expectedVersion", expectedVersion).fetch().rowsUpdated())
.flatMap(changed -> changed == 1 ? Mono.<Void>empty()
: Mono.error(new IllegalStateException("reservation rejected")))
.as(transactions::transactional);Verification boundary
The Java 21 / Spring Boot 4 source kit checks transactionCommitsMarkerAndVersionedDebitTogether and transactionRollsBackMarkerAfterRejectedDebit.
Cost and limits
One transaction holds a connection until completion and adds an indexed marker write. Long reactive chains inside the transaction can hold database resources for too long; keep remote calls and slow work outside the database commit window.
Common Mistakes
- Do not put the marker outside the transactional pipeline.
- Do not convert a failed row count into a normal completion if rollback is required.
- Do not assume a JDBC transaction joins a R2DBC transaction automatically.
Read next
Spring R2DBC conditional update: test tenant, version and stock floor in one statement, Spring consumer idempotency: reserve an event ID with the stock mutation, Spring TransactionTemplate: roll back a failed multi-row change, Spring transaction propagation: joined rollback and independent commit, Spring R2DBC fixture isolation: keep incompatible H2 versions out of one test classpath.
Related boundary
Spring reactive transaction: subscription owns the context, not a thread
