skip to content

R2DBC Constraints & Transactions

R2DBC has no relation mapping or lazy loading, so joins are yours to write, and transactions run through the reactive manager rather than a ThreadLocal. Interviewers ask so you can weigh those costs honestly against the blocking stack.

part ofSpring Frameworkoverview, primer and where to startread it →
on this pageshow

questions

5

Why does Spring Data R2DBC not support entity relationships or lazy loading, and what does that mean for how you model data?

level: juniorimportance: must knowfreq 70%

answer

  1. ORM-lite, not JPA
  2. no @OneToMany / lazy proxies
  3. lazy = hidden blocking fetch
  4. one entity = one table
  5. manual join via flatMap/zip

basics

~10 s

R2DBC is a non-blocking (reactive) database access layer. It has no lazy loading or @OneToMany/@ManyToOne relations because lazy loading would need a hidden blocking call. You fetch related rows with explicit extra queries yourself.

solid answer

~40 s

Spring Data R2DBC is a lightweight object mapper over a reactive (non-blocking) driver, not a full ORM like JPA/Hibernate. It deliberately omits relationship mapping (@OneToMany, @ManyToOne), lazy loading, dirty checking, and a persistence context. The core reason: lazy loading works by transparently issuing a query when you touch an unloaded field — that is a hidden, synchronous/blocking fetch, which is incompatible with the non-blocking contract where every I/O must be an explicit part of a Mono/Flux pipeline. So each entity maps to a single table/row; there are no navigable associations. To load related data you issue additional reactive queries and combine them (flatMap, zip) yourself. This keeps the model simple and every database round-trip visible and composable.

code

java · 20 lines
java
// Spring Data R2DBC entity: scalar columns only, NO relations
@Table("post")
public class Post {
    @Id private Long id;
    private String title;
    // NO @OneToMany List<Comment> comments;  <-- unsupported
}

public interface PostRepository extends ReactiveCrudRepository<Post, Long> {}
public interface CommentRepository extends ReactiveCrudRepository<Comment, Long> {
    Flux<Comment> findByPostId(Long postId);
}

// Load parent + children explicitly (the "manual join")
public Mono<PostView> loadPost(Long id) {
    return postRepo.findById(id)
        .flatMap(post -> commentRepo.findByPostId(post.getId())
            .collectList()
            .map(comments -> new PostView(post, comments)));
}

go deeper

for a junior

Know R2DBC is non-blocking and has no lazy loading or relations; you fetch related data with extra queries.

for a middle

Explain why lazy loading is fundamentally incompatible with non-blocking I/O, and show a flatMap-based manual join.

for a senior

Discuss the missing persistence context/dirty checking, N+1 mitigation via batched child queries, and when R2DBC vs JPA is the right tool.

for a principal

Weigh the architectural trade-off: simplicity/explicit I/O and backpressure vs. loss of ORM ergonomics; know there is no true reactive JPA in this stack and design aggregates accordingly.

**What R2DBC is.** R2DBC (Reactive Relational Database Connectivity) is a driver SPI that lets you talk to relational databases without blocking threads — results arrive as a `Publisher` (Reactor `Mono`/`Flux`) instead of a blocking `ResultSet`. **Spring Data R2DBC** is Spring's object mapper on top of it: it maps rows to objects, gives you `ReactiveCrudRepository`, `R2dbcEntityTemplate`, and query derivation. Crucially it is an *ORM-lite*, not a JPA/Hibernate replacement. **What it deliberately does NOT have:** - **No relationship mapping.** JPA annotations like `@OneToMany`, `@ManyToOne`, `@ManyToMany`, `@JoinColumn` are not supported. An entity maps to exactly one table; a field must be a scalar column (or an `@Embedded`/value object), never a collection of other entities. - **No lazy loading.** In JPA a lazy association is a proxy that silently runs a SQL query the moment you access it. That hidden fetch is **blocking** and **implicit** — it directly violates the reactive rule that all I/O be explicit and non-blocking. So R2DBC has no proxies and no lazy fetch. - **No persistence context / first-level cache / dirty checking.** There is no `EntityManager` tracking loaded entities. Calling `save()` always issues an INSERT or UPDATE; changing a field does not auto-flush. - **No cascade, no automatic identity map.** **Why non-blocking forbids lazy loading.** The reactive contract: a thread must never block waiting on I/O. Lazy loading depends on triggering I/O from an ordinary getter/property access deep inside business code — there is no `Mono` to subscribe to there, so the only way to "return the data" would be to block the calling thread. Reactive code refuses to do that; therefore every fetch must be a first-class step in the pipeline. **What you do instead — manual joins.** You load the aggregate root, then explicitly load children with a second query and stitch them together: ```java postRepo.findById(id) .flatMap(post -> commentRepo.findByPostId(post.getId()) .collectList() .map(comments -> new PostView(post, comments))); ``` Or you write a JOIN in a `@Query` and map the flat result with a custom converter, or use `DatabaseClient` for full control. **When to use R2DBC anyway.** Choose it when you need end-to-end non-blocking I/O (WebFlux, high-concurrency streaming, backpressure) and your access patterns are relatively simple/aggregate-oriented. If you need rich graph navigation, lazy graphs, and dirty checking, JPA/Hibernate (blocking) is a better fit — there is no reactive Hibernate ORM equivalent in the Spring Data R2DBC stack. **Gotchas:** - Putting `@OneToMany List<Comment>` on an R2DBC entity does not work — mapping will fail or ignore it. - N+1 is easy to reintroduce manually; batch child loads (e.g. `findByPostIdIn(...)`) instead of per-parent queries. - No dirty checking means you must call `save()` explicitly after mutating an entity.

  • How would you avoid an N+1 problem when loading many posts each with comments in R2DBC?
    Load all posts, collect their ids, then issue one batched child query (findByPostIdIn(ids)), group the comments by postId in memory (Collectors.groupingBy) and assemble the views — turning N+1 queries into 2. Alternatively write a single JOIN via @Query/DatabaseClient and map the flat rows.
  • Does @Embedded work in R2DBC?
    Yes. @Embedded flattens a value object's fields into columns of the same table — it is not a relationship (no separate table, no join), so it is fully supported. It is different from @OneToMany, which references another table and is unsupported.

saying these in an interview costs you the question

  • Claiming R2DBC supports @OneToMany/@ManyToOne with lazy loading like JPA
  • Thinking Spring Data R2DBC is 'reactive Hibernate' with a persistence context and dirty checking
  • Believing you can navigate associations lazily by just adding a getter

context

open as a page

How do you make multiple R2DBC operations run in one transaction? Contrast declarative @Transactional with the programmatic TransactionalOperator, and name the transaction manager involved.

level: middleimportance: must knowfreq 65%

basics

~10 s

Register an R2dbcTransactionManager (a ReactiveTransactionManager). Then either annotate a method returning Mono/Flux with @Transactional, or wrap the pipeline programmatically with TransactionalOperator.transactional(...). Commit happens on completion, rollback on an error signal.

open as a page

Show the ways to fetch and assemble related data in Spring Data R2DBC given there are no mapped relations. What are the trade-offs?

level: middleimportance: should knowfreq 55%

basics

~20 s

Either run separate reactive queries and combine them (flatMap/zip), or write a JOIN in a @Query and map the flat rows into a DTO with a custom converter, or use DatabaseClient for raw control. There is no automatic association mapping.

open as a page

How is a reactive R2DBC transaction propagated across operators and thread hops when there is no ThreadLocal? What breaks that propagation?

level: seniorimportance: should knowfreq 45%

basics

~20 s

The transactional connection is stored in the Reactor subscriber Context, which travels with the reactive chain regardless of which thread runs each step. Anything that starts a new, separate subscription (a fresh subscribe, a detached publisher, or blocking code) escapes that Context and the transaction.

open as a page

As a principal engineer, explain the non-blocking driver and connection semantics of R2DBC that shape how you design a transactional, high-concurrency data layer — and the traps of mixing blocking code in.

level: principalimportance: should knowfreq 35%

basics

~20 s

R2DBC drivers do database I/O without blocking threads: results are Publishers demanded via backpressure over a small pooled set of connections. A transaction pins one connection for its whole span, so long chains hold connections; and one blocking call on an event-loop thread can stall the entire app.

open as a page