Field note / spring-webflux

publishedupdated 2026-05-01#r2dbc#spring-data#reactive#postgresql#transactions

R2DBC — Reactive SQL

Reactive relational database access with R2DBC, Spring Data R2DBC, transaction management, and N+1 query patterns in reactive context.

Overview

R2DBC (Reactive Relational Database Connectivity) is the reactive counterpart to JDBC. Traditional JDBC blocks the calling thread while waiting for the database — in a reactive application this creates a thread mismatch: your Netty event loop thread would block. R2DBC uses database-specific reactive drivers to make SQL queries non-blocking, returning Mono<T> and Flux<T> from repository methods.


Architecture

JDBC (Blocking) vs R2DBC (Non-Blocking)
════════════════════════════════════════════════════════

  JDBC:
  Netty thread → jdbc.query() → BLOCKS → wait for DB → result
  (Netty event loop thread is blocked — can't handle other requests)

  Workaround: subscribeOn(Schedulers.boundedElastic())
  → offload to I/O thread pool, unblocks Netty

  R2DBC:
  Netty thread → r2dbc.query() → subscribe → returns immediately
  DB driver → TCP to Postgres → async → callback when result arrives
  Netty thread picks up result → continues reactive pipeline
  (Event loop never blocked)

R2DBC SUPPORT MATRIX
════════════════════════════════════════════════════════
  PostgreSQL  → r2dbc-postgresql (Pivotal)
  MySQL       → r2dbc-mysql (asyncer)
  H2          → r2dbc-h2 (in-memory, testing)
  MSSQL       → r2dbc-mssql (Microsoft)
  Oracle      → r2dbc-oracle (Oracle)

  NOT supported (as of 2026): DynamoDB, MongoDB (use reactive mongo driver)
  Stored procedures: supported via r2dbc DatabaseClient

TRANSACTION MODEL
════════════════════════════════════════════════════════
  @Transactional works with R2DBC via ReactiveTransactionManager.
  Transaction state bound to Reactive Context (not ThreadLocal like JDBC).
  Each reactive chain step carries the transaction in its Subscriber context.

  R2DBC LIMITATION vs JPA:
  No lazy loading (no Hibernate persistence context)
  No bidirectional relationship auto-management
  JOINs must be done manually via DatabaseClient
  No L1/L2 cache

Technical Implementation

Spring Data R2DBC Repository

// Entity
@Table("orders")
public record Order(
    @Id Long id,
    @Column("user_id") String userId,
    BigDecimal total,
    @Column("status") OrderStatus status,
    @CreatedDate Instant createdAt
) {}

// Repository — Spring Data generates implementations
public interface OrderRepository extends ReactiveCrudRepository<Order, Long> {

  Flux<Order> findByUserId(String userId);

  @Query("SELECT * FROM orders WHERE user_id = :userId AND status = :status ORDER BY created_at DESC LIMIT :limit")
  Flux<Order> findRecentByUserAndStatus(String userId, String status, int limit);

  @Query("UPDATE orders SET status = :status WHERE id = :id AND status = 'PENDING'")
  Mono<Integer> tryUpdateStatus(Long id, String status);  // returns affected row count

  @Query("SELECT COUNT(*) FROM orders WHERE user_id = :userId AND created_at > :since")
  Mono<Long> countRecentOrders(String userId, Instant since);
}

@Transactional in Reactive Context

@Service
@Transactional  // class-level: all methods are transactional by default
public class OrderService {

  private final OrderRepository orderRepo;
  private final InventoryRepository inventoryRepo;
  private final R2dbcEntityTemplate r2dbc;

  // Transaction spans the entire reactive chain
  @Transactional
  public Mono<Order> placeOrder(OrderRequest req) {
    return inventoryRepo.findByProductId(req.productId())
        .switchIfEmpty(Mono.error(new ProductNotFoundException(req.productId())))
        .flatMap(inventory -> {
          if (inventory.quantity() < req.qty()) {
            return Mono.error(new InsufficientStockException());
          }
          // Both updates in the same transaction
          return inventoryRepo.decrementStock(req.productId(), req.qty())
              .then(orderRepo.save(new Order(null, req.userId(), req.total(),
                  OrderStatus.CONFIRMED, Instant.now())));
        });
    // Any error in the chain → transaction rolls back automatically
  }

  // Read-only transaction for optimization
  @Transactional(readOnly = true)
  public Flux<Order> getUserOrders(String userId) {
    return orderRepo.findByUserId(userId);
  }
}

Complex Queries with DatabaseClient

// Spring Data R2DBC repositories don't support JOINs natively.
// Use DatabaseClient for complex queries.
@Repository
public class OrderQueryRepository {

  private final DatabaseClient db;

  // JOIN query with manual mapping
  public Flux<OrderWithProduct> findOrdersWithProducts(String userId) {
    return db.sql("""
        SELECT o.id, o.total, o.status, o.created_at,
               p.name AS product_name, p.sku
        FROM orders o
        JOIN order_items oi ON oi.order_id = o.id
        JOIN products p ON p.id = oi.product_id
        WHERE o.user_id = :userId
        ORDER BY o.created_at DESC
        """)
        .bind("userId", userId)
        .fetch()
        .all()
        .map(row -> new OrderWithProduct(
            ((Number) row.get("id")).longValue(),
            (BigDecimal) row.get("total"),
            OrderStatus.valueOf((String) row.get("status")),
            (String) row.get("product_name"),
            (String) row.get("sku")
        ));
  }

  // Bulk insert using flux
  public Flux<Order> insertBatch(List<OrderRequest> requests) {
    return Flux.fromIterable(requests)
        .flatMap(req ->
            db.sql("INSERT INTO orders (user_id, total, status) VALUES (:userId, :total, 'PENDING') RETURNING *")
              .bind("userId", req.userId())
              .bind("total", req.total())
              .fetch()
              .one()
              .map(row -> mapToOrder(row)),
            10  // concurrency: 10 parallel inserts
        );
  }
}

Interview Preparation

Q: Why can't you use JDBC directly in a Spring WebFlux application?

JDBC is inherently blocking — ResultSet.next(), PreparedStatement.executeQuery() all block the calling thread until the database responds. In Spring WebFlux, HTTP requests are handled by a small number of Netty event loop threads (usually ncpus × 2). If one of those threads calls JDBC and blocks for 50ms waiting for a DB response, it cannot handle any other requests during that time. At 100 concurrent requests, all event loop threads could be blocked simultaneously. The workaround is Mono.fromCallable(() -> jdbcCall()).subscribeOn(Schedulers.boundedElastic()) — this offloads the blocking call to a separate thread pool. R2DBC eliminates this entirely by providing truly non-blocking DB access.

Q: How does @Transactional work differently in reactive vs blocking Spring?

In blocking Spring, @Transactional stores the transaction state (connection, session) in a ThreadLocal — every method in the same thread participates in the same transaction. In reactive Spring (R2DBC), there are no thread-local guarantees (operators can switch threads). Instead, the transaction is stored in the Reactor Context — a per-subscription key-value store that flows through the entire reactive chain regardless of which thread executes each operator. ReactiveTransactionManager binds the transaction to the current Subscriber context. This means you must NOT switch to a different subscriber context (e.g., Mono.fromCallable().subscribeOn()) inside a @Transactional reactive method — doing so creates a new context and a new transaction.

Q: How do you handle the N+1 query problem in R2DBC without lazy loading?

R2DBC has no Hibernate-style lazy loading proxy. There's no way to write order.getProducts() and have it transparently fetch associated data. Solutions: (1) Write an explicit JOIN query with DatabaseClient and map the flat result set to a nested object — one query fetches everything. (2) Use flatMap to fetch associations explicitly — load orders, then for each order load its items in a flatMap (N+1 is explicit and you can control concurrency with flatMap(fn, concurrency)). (3) For simple relationships, use Spring Data @Query with a JOIN. The key: with R2DBC, you're aware of every query — there's no hidden lazy loading to accidentally trigger.


Learning Resources