skip to content

Spring Data JDBC & R2DBC

The lighter relational modules: Spring Data JDBC with its aggregate-oriented model, and R2DBC for reactive access. Interviewers use them to check whether you know there is life between raw JDBC and full JPA.

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

explore

questions

25

In Spring Data JDBC, what is an aggregate and an aggregate root, and how are they persisted?

level: juniorimportance: must knowfreq 60%

answer

  1. cluster + root = consistency unit
  2. one repository per root only
  3. children reached through root
  4. save/load act on whole aggregate
  5. cross-aggregate = reference by id

basics

~20 s

An aggregate is a group of related objects saved and loaded as one unit. The aggregate root is the top object you access it through. Spring Data JDBC has one repository per root and saves the whole aggregate together.

solid answer

~40 s

Spring Data JDBC follows Domain-Driven Design aggregates: an aggregate is a cluster of an entity plus the child entities it owns, treated as a single consistency unit. The aggregate root is the entity outside code references; children are reached only through it. You get exactly one repository (e.g. CrudRepository) per aggregate root, and every save or load acts on the whole aggregate. When you save the root, its owned children (mapped via @MappedCollection) are written too; when you load the root, they are all fetched eagerly. There is no repository for children. This keeps a clear ownership boundary: the root controls the lifecycle of everything it contains, so transactional consistency is scoped to one aggregate at a time.

code

java · 20 lines
java
// Aggregate root
class Order {
    @Id Long id;
    String customerRef;                 // cross-aggregate ref: just an id, not an object
    @MappedCollection(idColumn = "order_id")
    Set<OrderItem> items = new HashSet<>();
}

// Owned child — NO repository of its own
class OrderItem {
    String sku;
    int qty;
}

// Exactly one repository: for the root
interface OrderRepository extends CrudRepository<Order, Long> {}

// save writes Order + all items; findById loads them all eagerly
orderRepository.save(order);
Order loaded = orderRepository.findById(1L).orElseThrow();

go deeper

for a junior

Know the definitions: aggregate = group saved/loaded together, root = the top entity, one repository per root.

for a middle

Explain that children map to child tables and are written on the root's save; cross-aggregate links are ids.

for a senior

Frame the aggregate as the transaction/consistency boundary and justify keeping aggregates small.

for a principal

Discuss aggregate design trade-offs: boundaries, referencing by id vs embedding, and how this shapes the domain model and load/write cost.

**Spring Data JDBC** maps objects to relational tables using JDBC directly — deliberately simpler than JPA/Hibernate: no proxies, no persistence context, no lazy loading, no dirty tracking. Its central design idea is the **aggregate**, borrowed from Domain-Driven Design (DDD). **Aggregate** — a cluster of objects that belong together and must stay consistent as a unit. Example: an `Order` with its `OrderItem` lines forms one aggregate. **Aggregate root** — the single entity that is the entry point. Outside code references only the root; child entities are reached *through* it, never loaded or saved on their own. In the example `Order` is the root and `OrderItem` is an owned child. **One repository per aggregate root.** You declare a repository (e.g. `interface OrderRepository extends CrudRepository<Order, Long>`) only for roots. You do **not** create an `OrderItemRepository`. This enforces the rule that children have no independent lifecycle. **Whole-aggregate operations.** - `save(order)` writes the root row *and* all its owned children (each child collection maps to a child table via `@MappedCollection`). - `findById(id)` loads the root *and* eagerly fetches every child — there is no lazy loading, so what you get back is the complete aggregate. - `delete(order)` removes the root and cascades to its children. **Why aggregates matter.** The aggregate is the **consistency and transaction boundary**. Spring Data JDBC guarantees consistency only *within* one aggregate. References between *different* aggregates are modeled not as object references but as **plain id values** (e.g. store a `customerId` field, not a `Customer` object). This keeps aggregates small and independently loadable, and avoids accidentally dragging half the database into memory. **Gotchas / when to use.** Because the whole aggregate is loaded and rewritten together, keep aggregates small — a root with thousands of children means huge loads and saves. Model only truly-owned, lifecycle-bound data as children; everything else is a cross-aggregate reference by id. Use Spring Data JDBC when you want explicit, predictable SQL and a clean DDD ownership model; reach for JPA when you need lazy graphs, dirty tracking, or complex inheritance mapping.

  • How do you model a reference from one aggregate to another?
    Not with an object reference — store the other aggregate's id as a plain field (e.g. a Long customerId or an AggregateReference). This keeps aggregates independently loadable and preserves the consistency boundary.
  • Why is there no repository for child entities?
    Children have no independent lifecycle; they exist only as part of the root's aggregate. Giving them a repository would let code load/save them outside the root, breaking the ownership and consistency boundary DDD aggregates enforce.

saying these in an interview costs you the question

  • Thinking every entity gets its own repository
  • Modeling cross-aggregate links as object references instead of ids
  • Assuming children can be loaded independently of the root
  • Believing children are lazy-loaded like in JPA

context

open as a page

In Spring Data JDBC, what language does the @Query annotation expect, and how does that differ from Spring Data JPA?

level: juniorimportance: must knowfreq 70%

basics

~10 s

@Query in Spring Data JDBC takes plain, database-specific SQL. Spring Data JPA's @Query defaults to JPQL (an entity-oriented query language). Spring Data JDBC has no JPQL, so you always write real SQL.

open as a page

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%

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.

open as a page

What is an R2dbcRepository, and what do its query methods return?

level: juniorimportance: must knowfreq 72%

basics

~10 s

R2dbcRepository is Spring Data's reactive repository for relational databases. Its methods return reactive types: Mono<T> for zero-or-one result and Flux<T> for many, instead of blocking values or List.

open as a page

What is AggregateReference in Spring Data JDBC, why does it exist, and how does it behave on read and write?

level: middleimportance: must knowfreq 40%

basics

~20 s

AggregateReference<T, ID> is a typed link to another aggregate root by its id only. It stores the foreign-key id in a column but does NOT load the target object — reading gives you just getId(); you fetch the referenced aggregate separately if you need it.

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

Why does Spring Data JDBC have no lazy loading and no dirty tracking, and how does that change how you write code compared to JPA?

level: seniorimportance: must knowfreq 55%

basics

~20 s

Spring Data JDBC loads the whole aggregate immediately (no proxies) and returns plain objects with no persistence context tracking them. Changing a field does nothing until you explicitly call save(); there is no automatic flush.

open as a page

In Spring Data JDBC, how are table and column names resolved for an entity, and does the default convert camelCase to snake_case?

level: juniorimportance: should knowfreq 45%

basics

~10 s

Names come from the NamingStrategy. The default uses the class and property names as-is — no automatic snake_case. Property firstName maps to column firstName, not first_name, unless you override it.

open as a page

How does @MappedCollection map child entities to a child table in Spring Data JDBC?

level: middleimportance: should knowfreq 50%

basics

~20 s

@MappedCollection maps a collection field on the root to a separate child table. Its idColumn is the foreign key back to the parent, and keyColumn holds the list index or map key so ordered/keyed collections round-trip correctly.

open as a page

What does @Embedded do in Spring Data JDBC, and what do its onEmpty and prefix attributes control?

level: middleimportance: should knowfreq 35%

basics

~20 s

@Embedded flattens a value object's properties into columns of the owner's table instead of a separate table. prefix adds a string before each embedded column name; onEmpty (USE_NULL or USE_EMPTY) decides whether an all-null embedded reads back as null or as an empty object.

open as a page

How do parameter binding and @Modifying work for @Query methods in Spring Data JDBC, including collection parameters and return types?

level: middleimportance: should knowfreq 45%

basics

~10 s

Bind arguments with named parameters :name plus @Param. A collection bound to an IN (:ids) clause expands to multiple placeholders. Write queries need @Modifying and return void, int/long (rows affected), or boolean.

open as a page

How does a custom RowMapper work with @Query in Spring Data JDBC, and when would you use one instead of the default mapping?

level: middleimportance: should knowfreq 50%

basics

~20 s

A RowMapper turns one ResultSet row into an object via mapRow(rs, rowNum). You attach it with @Query(rowMapperClass = X.class) or rowMapperRef="beanName". Use one when the default entity mapping cannot produce your shape, like a custom DTO from a join.

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

What is R2dbcEntityTemplate and when would you use it instead of a repository?

level: middleimportance: should knowfreq 48%

basics

~10 s

R2dbcEntityTemplate is a mid-level helper that maps entities to/from rows using a fluent, type-safe API (select/insert/update/delete with Query and Criteria). Use it for dynamic queries a derived repository method cannot express.

open as a page

What happens to child rows when you save an existing aggregate in Spring Data JDBC, and what are the implications?

level: seniorimportance: should knowfreq 45%

basics

~20 s

On updating a root, Spring Data JDBC deletes all its existing child rows and re-inserts the current ones, because it can't tell which children changed. This can churn auto-generated child ids and cause extra DELETE/INSERT SQL.

open as a page

What is JdbcAggregateTemplate and when would you use it instead of a repository?

level: seniorimportance: should knowfreq 40%

basics

~20 s

JdbcAggregateTemplate is the lower-level engine behind Spring Data JDBC repositories. It offers programmatic aggregate operations, insert, update, save, delete, findById, findAll, count, without declaring a repository interface. Use it for dynamic or fine-grained control over persistence.

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

How does the DatabaseClient fluent API work for executing SQL in R2DBC?

level: seniorimportance: should knowfreq 45%

basics

~10 s

DatabaseClient is the low-level fluent API: databaseClient.sql("...").bind("name", value).map(row -> ...).all() (or .one()/.first()/.rowsUpdated()). It runs raw SQL with named parameter binding and manual row mapping, returning Mono/Flux.

open as a page

How does save() decide between INSERT and UPDATE in Spring Data R2DBC, and what breaks with assigned IDs?

level: seniorimportance: should knowfreq 38%

basics

~20 s

save() looks at the entity's @Id: if it is null (or 0 for primitives) the entity is treated as new and INSERTed; otherwise it is UPDATEd. With a manually assigned id, save() wrongly does an UPDATE that affects zero rows.

open as a page

How does Spring Data JDBC decide between INSERT and UPDATE, and how do you make it work with assigned (non-generated) @Ids?

level: principalimportance: should knowfreq 40%

basics

~20 s

By default save() treats an entity as new (INSERT) when its @Id is null (or 0 for a primitive), otherwise UPDATE. With ids you assign yourself, a non-null id looks 'existing', so you implement Persistable.isNew(), add a @Version field, or set the id in a callback.

open as a page

Spring Data JDBC has no identity map or first-level cache. What does that mean for the mapping model and how does it shape aggregate design?

level: principalimportance: should knowfreq 30%

basics

~20 s

Spring Data JDBC doesn't track loaded entities. Loading the same row twice returns two distinct objects; there's no dirty checking, no lazy loading, no identity map. You load a whole aggregate and save the whole aggregate explicitly, and cross-aggregate links use AggregateReference (id only).

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

How do transactions work with reactive R2DBC repositories, and why can't they use thread-local state?

level: principalimportance: should knowfreq 30%

basics

~10 s

Reactive transactions are managed by R2dbcTransactionManager and bound to the Reactor subscriber context, not to a thread. Use @Transactional on a method returning Mono/Flux, or TransactionalOperator, so the commit/rollback follows the reactive chain.

open as a page

How do you customize naming globally in Spring Data JDBC, and which NamingStrategy methods matter beyond table and column names?

level: seniorimportance: nice to knowfreq 25%

basics

~10 s

Register a NamingStrategy bean and override its methods. Beyond getTableName and getColumnName, you can override getSchema (schema prefix), getReverseColumnName (back-reference FK in child tables) and getKeyColumn (list index / map key column).

open as a page

Compare @Query repositories, JdbcAggregateTemplate, and NamedParameterJdbcTemplate. When do you choose each, and what are the architectural trade-offs?

level: principalimportance: nice to knowfreq 25%

basics

~20 s

Use @Query repositories for declarative CRUD and hand SQL reads. Use JdbcAggregateTemplate for programmatic aggregate operations and explicit insert/update control. Use NamedParameterJdbcTemplate for raw SQL that has no aggregate mapping, like reports or bulk operations.

open as a page