skip to content

JDBC Mapping Model

Mapping is deliberately simple: a naming strategy for tables and columns, AggregateReference instead of an object link across aggregates, no identity map or cache. Explaining what is missing compared with JPA is exactly what the question is after.

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

questions

5

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%

answer

  1. Reference other aggregates by id, not object
  2. AggregateReference<T, ID>
  3. AggregateReference.to(id) to create
  4. Read gives getId() only — no load, no join
  5. IdOnlyAggregateReference; cross-aggregate boundary

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.

solid answer

~40 s

`AggregateReference<T, ID>` (org.springframework.data.jdbc.core.mapping) models a reference from one aggregate root to another **by id**, honoring the DDD rule that aggregates reference each other by identity, not by object graph. On write it stores the referenced aggregate's id in a plain foreign-key column (named via NamingStrategy from the property). On read Spring Data JDBC populates an `AggregateReference` (the `IdOnlyAggregateReference` implementation) whose `getId()` returns the id — it does **not** join, load, or lazily fetch the target aggregate. If you need the full object you query its repository yourself. You create one with the static factory `AggregateReference.to(id)`. This is deliberately different from within-aggregate references (`@MappedCollection`, embedded objects), which Spring Data JDBC loads and saves as one unit. AggregateReference is the boundary marker between aggregates.

code

java · 22 lines
java
import org.springframework.data.jdbc.core.mapping.AggregateReference;
import org.springframework.data.annotation.Id;

class Book {
    @Id Long id;
    String title;
    // link to the Author aggregate by id only:
    AggregateReference<Author, Long> author;   // column "author" holds author.id
}

class Author { @Id Long id; String name; }

// --- writing ---
Book b = new Book();
b.title = "Clean Aggregates";
b.author = AggregateReference.to(42L);   // just stores the id 42
bookRepository.save(b);

// --- reading ---
Book loaded = bookRepository.findById(1L).orElseThrow();
Long authorId = loaded.author.getId();   // 42 -- Author itself is NOT loaded
Author author = authorRepository.findById(authorId).orElseThrow(); // explicit fetch

go deeper

for a junior

Know it links to another aggregate by id and getId() is all you get on read.

for a middle

Explain create via AggregateReference.to(id), FK-column storage, and no automatic loading.

for a senior

Contrast within-aggregate (@MappedCollection) vs cross-aggregate references and the cascade/delete implications.

for a principal

Use aggregate boundaries + AggregateReference to design explicit load/consistency boundaries and avoid N+1 traversal.

## The DDD idea behind it Spring Data JDBC is built around **Domain-Driven Design aggregates**. An *aggregate* is a cluster of objects treated as one unit for loading and saving, with a single *aggregate root*. A core DDD rule: **aggregates reference other aggregates only by identity (id), never by holding the other aggregate's object graph.** `AggregateReference` is the concrete API for that rule. ## The type `AggregateReference<T, ID>`: - `T` = the referenced aggregate root type (e.g. `Person`). - `ID` = the type of that aggregate's id (e.g. `Long`). In your entity you declare a field like `AggregateReference<Person, Long> owner;`. ## Persistence behavior - **Storage:** it maps to a **single foreign-key column** holding the referenced id. The column name comes from the NamingStrategy applied to the property name (e.g. property `owner` → column `owner`), overridable with `@Column`. - **Write:** `AggregateReference.to(personId)` creates a reference; saving the owner writes `personId` into the column. Spring Data JDBC does **not** cascade-save the referenced aggregate — you are only storing its id. - **Read:** the framework reconstitutes an `AggregateReference` whose `getId()` returns the stored id. The concrete class is `IdOnlyAggregateReference`. **No join is issued and the target aggregate is not materialized.** There is no lazy proxy — you literally only have the id. ## Getting the actual target Because nothing is loaded, if you need the referenced `Person` you call `personRepository.findById(ref.getId())` explicitly. This keeps each aggregate's load boundary explicit and avoids surprise N+1 traversals. ## Contrast with within-aggregate references - `@MappedCollection` / nested entities / `@Embedded` → **within** the aggregate; loaded and saved together with the root, deleted with it. - `AggregateReference` → **across** aggregate boundaries; only the id crosses, nothing is loaded or cascaded. ## Gotchas - People expect `getId()`-plus-object like a JPA `@ManyToOne`; there is no managed target and no lazy loading in Spring Data JDBC. - Deleting the owner does **not** delete the referenced aggregate (correct — it is a separate aggregate). - The referenced id must exist for referential integrity if you have a DB FK constraint; Spring Data JDBC won't create the target for you. - `AggregateReference.to(null)` / mapping a null column yields a null reference (no id). - The id type parameter must match the target's `@Id` type, or reads/writes fail type conversion.

  • How does AggregateReference differ from a @MappedCollection child within the aggregate?
    A @MappedCollection child is part of the same aggregate: it is loaded, saved, and deleted together with the root. AggregateReference points to a separate aggregate — only its id is stored; the target is never loaded or cascaded.
  • I loaded a Book and want the Author object. What do I do?
    Read the id with book.author.getId() and call authorRepository.findById(id) yourself. Spring Data JDBC does not lazily load or join the referenced aggregate.

saying these in an interview costs you the question

  • Expecting AggregateReference to lazily load the target like a JPA @ManyToOne
  • Thinking saving the owner cascades a save/insert of the referenced aggregate
  • Assuming deleting the owner deletes the referenced aggregate

context

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

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

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

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