Add jdbc-vs-jpa module: Spring Data JDBC vs JPA on a shared Order aggregate

Same Customer/Order/OrderItem domain modelled with Hibernate/Spring Data JPA and
Spring Data JDBC side by side, both running through a shared StatementLoggingDataSource
so SQL-statement counts are directly comparable. 11 tests, 11 captured transcripts,
6 doc chapters. Companion repo for the ankurm.com article on when to drop the ORM.
This commit is contained in:
2026-09-17 19:09:35 +00:00
parent 585eed4d57
commit 448c383879
41 changed files with 1811 additions and 17 deletions
+37
View File
@@ -0,0 +1,37 @@
# 01 — The shared domain
[← README](../README.md) · [next: the JPA side →](02-the-jpa-side.md)
Both stacks in this module model the same thing: a `Customer` who places `Order`s, each with a
list of `OrderItem`s. It is deliberately the smallest domain that has everything worth arguing
about — a one-to-many collection, a reference to a second aggregate, and a field a reader will
want to mutate in place.
| Concept | JPA package (`com.ankurm.jdbcvsjpa.jpa`) | JDBC package (`com.ankurm.jdbcvsjpa.jdbc`) |
|---|---|---|
| Table prefix | `jpa_*` | `JDBC_*` (see the identifier-casing note below) |
| Order → Customer | `@ManyToOne(fetch = LAZY)` object reference | `AggregateReference<Customer, Long>` — a typed foreign key, never a loaded object |
| Order → items | `@OneToMany(mappedBy, cascade = ALL, orphanRemoval = true)`, `List<OrderItem>` | `@MappedCollection(idColumn, keyColumn)`, `List<OrderItem>` |
| Identity of a child row | `@ManyToOne` back-reference to its parent | none — a JDBC `OrderItem` has no idea which Order owns it |
| Optimistic locking | `@Version Long version` | `@Version Long version` (same annotation, same package: `org.springframework.data.annotation`) |
Both sides run against the **same H2 database** through the **same
[`StatementLoggingDataSource`](../src/main/java/com/ankurm/jdbcvsjpa/support/StatementLoggingDataSource.java)**
— see [03 — the SQL log](03-the-sql-log.md) for why that matters for the numbers in the article.
## A casing trap that cost a debugging session
Spring Data JDBC treats an explicit `@Table("some_name")` or `@Column("some_name")` value as a
*literal, quoted* SQL identifier — it is rendered exactly as given, in double quotes. Properties
with no explicit annotation go through the H2 dialect's default identifier processing instead,
which upper-cases them. H2 itself upper-cases any *unquoted* identifier in DDL. The result: an
unquoted `create table jdbc_customer(...)` in `schema.sql` produces a table H2 privately calls
`JDBC_CUSTOMER`, but `@Table("jdbc_customer")` generates `INSERT INTO "jdbc_customer" (...)`
quoted, lower-case, and therefore a different identifier as far as H2 is concerned. The fix used
throughout this module is to give every explicit `@Table`/`@Column`/`@MappedCollection` value in
upper case, matching H2's own folding, so the literal and the folded name agree. See
[`jdbc/Order.java`](../src/main/java/com/ankurm/jdbcvsjpa/jdbc/Order.java) for the annotations
and [`schema.sql`](../src/main/resources/schema.sql) for the DDL. This is exactly the kind of
thing that never shows up in a tutorial that only ever runs one save and eyeballs the console.
Continue to [02 — the JPA side](02-the-jpa-side.md).
+47
View File
@@ -0,0 +1,47 @@
# 02 — The JPA side
[← 01 the shared domain](01-the-shared-domain.md) · [next: the SQL log →](03-the-sql-log.md)
Source: [`jpa/Order.java`](../src/main/java/com/ankurm/jdbcvsjpa/jpa/Order.java),
[`jpa/OrderItem.java`](../src/main/java/com/ankurm/jdbcvsjpa/jpa/OrderItem.java),
[`jpa/Customer.java`](../src/main/java/com/ankurm/jdbcvsjpa/jpa/Customer.java). Tests:
[`JpaBehaviorTest.java`](../src/test/java/com/ankurm/jdbcvsjpa/JpaBehaviorTest.java).
## The five things demonstrated here
1. **Lazy collections need a live session.** [Scenario 01](output/01-lazy-outside-session.txt)
loads an `Order` inside a transaction, returns it, and touches `.getItems()` after that
transaction (and the Hibernate session it owned) has closed —
`LazyInitializationException: ... (no session)`. `spring.jpa.open-in-view: false` in
`application.yml` is what makes the session close at the transaction boundary instead of
silently staying open for the rest of the request; open-in-view defaults to `true` in plain
Spring Boot and papers over exactly this failure until it happens somewhere without a
surrounding web request.
2. **The default fetch shape is N+1.** [Scenario 02](output/02-n-plus-one.txt): `findAll()` for
3 orders, then `.getItems().size()` on each in a loop, produces 1 + 3 = 4 SELECTs. Nothing
about the code looks wrong; it is the ordinary shape of a service method.
3. **`@EntityGraph` flattens it to a constant.** [Scenario 03](output/03-entity-graph-fixed-cost.txt)
re-runs the same lookup through `findWithItemsAndCustomerById`, an `@EntityGraph` query, for a
1-item and a 5-item order — both take exactly 1 statement. This is the fix for scenario 2, and
it is also the default (no annotation needed) on the JDBC side; see
[05 — the aggregate boundary](05-the-aggregate-boundary.md).
4. **Dirty checking writes before you call save().**
[Scenario 04](output/04-dirty-checking-autoflush.txt): inside one transaction, mutate a
managed item's `quantity` field directly — no `save()` call anywhere — then run an unrelated
query. The pending UPDATE appears *before* that second query runs, because Hibernate flushes
dirty state ahead of anything that could otherwise see stale data. "I never called save()" is
not evidence that nothing was written.
5. **`orphanRemoval` is what turns "removed from the list" into a DELETE.**
[Scenario 05](output/05-orphan-removal-delete.txt): `order.removeItem(item)` followed by
`save()` deletes that row because `orphanRemoval = true` is set on the `@OneToMany`. Drop that
attribute and the row survives with a stale `order_id` — the more common real bug report.
## What most tutorials don't show
Every one of the five behaviours above is *correct* — this is not a list of JPA bugs. The
argument this article makes is narrower: each of these five facts has to be known in advance and
opted into (an `@EntityGraph`, an `orphanRemoval = true`, an awareness of the flush timing) for
the obvious code to do the obvious thing. Spring Data JDBC, covered next, gets three of these five
for free by not having the mechanism that causes them.
Continue to [03 — the SQL log](03-the-sql-log.md).
+48
View File
@@ -0,0 +1,48 @@
# 03 — The SQL log
[← 02 the JPA side](02-the-jpa-side.md) · [next: the JDBC side →](04-the-jdbc-side.md)
Source: [`support/StatementLoggingDataSource.java`](../src/main/java/com/ankurm/jdbcvsjpa/support/StatementLoggingDataSource.java),
[`support/SqlLog.java`](../src/main/java/com/ankurm/jdbcvsjpa/support/SqlLog.java). Endpoint:
[`DiagController.java`](../src/main/java/com/ankurm/jdbcvsjpa/DiagController.java) at `/diag/sql-log`.
Every statement-count claim in this article comes from one mechanism, not from reading
Hibernate's `show_sql` output in one format and Spring Data JDBC's `JdbcTemplate` logging in a
different one. `StatementLoggingDataSource` wraps the single H2 `DataSource` both stacks share:
it hands out a JDK dynamic proxy for every `Connection`, which in turn hands out a proxy for
every `Statement`/`PreparedStatement`, and any method starting with `execute` gets its SQL text
recorded to `SqlLog` before the call is delegated. This is deliberately *below* both ORMs — it
counts what actually reached the database driver, not what each framework's own debug logging
chose to print.
```java
@Bean
public DataSource dataSource(SqlLog sqlLog) {
HikariDataSource real = new HikariDataSource();
real.setJdbcUrl("jdbc:h2:mem:jdbcvsjpa;DB_CLOSE_DELAY=-1;MODE=LEGACY");
// ...
return new StatementLoggingDataSource(real, sqlLog);
}
```
Two things this caught that a naive count would have missed:
- **JDBC batching.** Saving three `OrderItem` rows as part of one aggregate save
(see [07](output/07-delete-then-insert.txt)) shows up as *one* `INSERT` line, not three —
Spring Data JDBC calls `addBatch()` three times and `executeBatch()` once. Counting SQL text
seen by `Statement.executeQuery`/`executeUpdate` would have reported 3 inserts; counting actual
`execute*` invocations on the proxy correctly reports 1, because that is the true number of
round trips to the database.
- **Consistent counting across two completely different SQL-generation paths.** Hibernate's HQL
compiler and Spring Data JDBC's `JdbcTemplate`-based query building produce differently
formatted SQL for the same logical operation (see the JPA transcripts' lower-case, unquoted
style versus the JDBC transcripts' upper-case, quoted style — both are the frameworks' own
defaults, untouched). A statement counter that lived inside either framework would only ever
see its own side; this one sees both, so [00 — the comparison table](output/00-statement-count-comparison.txt)
is a fair, apples-to-apples number.
`/diag/sql-log` exposes the same log at runtime for manual exploration — hit an endpoint, then
`curl localhost:8080/diag/sql-log` to see exactly what ran. Delete this controller before
shipping; it has no business existing outside a demo.
Continue to [04 — the JDBC side](04-the-jdbc-side.md).
+50
View File
@@ -0,0 +1,50 @@
# 04 — The JDBC side
[← 03 the SQL log](03-the-sql-log.md) · [next: the aggregate boundary →](05-the-aggregate-boundary.md)
Source: [`jdbc/Order.java`](../src/main/java/com/ankurm/jdbcvsjpa/jdbc/Order.java),
[`jdbc/OrderItem.java`](../src/main/java/com/ankurm/jdbcvsjpa/jdbc/OrderItem.java),
[`jdbc/Customer.java`](../src/main/java/com/ankurm/jdbcvsjpa/jdbc/Customer.java). Tests:
[`JdbcBehaviorTest.java`](../src/test/java/com/ankurm/jdbcvsjpa/JdbcBehaviorTest.java).
## findById always returns the whole aggregate
[Scenario 06](output/06-fixed-cost-load.txt): `findById` on a 1-item order and on a 5-item order
both take exactly 2 statements (one for the order row, one for its items, via a join-free
two-query load — see the actual SQL in the transcript). There is no lazy/eager choice to make
because there is no lazy loading. This is not an optimization Spring Data JDBC performs; it is
the *only* thing it knows how to do, because an aggregate is, by definition, loaded and saved as
one unit.
## Saving replaces the whole collection — but as one batch
[Scenario 07](output/07-delete-then-insert.txt): changing the quantity on exactly one of three
`OrderItem`s and saving the aggregate produces a `DELETE ... WHERE order_id = ?` for all three
existing rows and then a *single* batched `INSERT` (three rows, one JDBC batch execution — see
[03](03-the-sql-log.md) for how that's verified). Spring Data JDBC does not diff collections; it
does not need to know which row changed, because it never tries to compute a diff. The cost is
real (every row's data is retransmitted and reinserted, not just the one that changed) but it is
one delete and one batch insert, not `N+1` round trips. For a collection in the tens of rows this
is invisible. For a collection in the tens of thousands, see
[06 — when this gets expensive](06-when-this-gets-expensive.md).
## `keyColumn` is what makes list order survive a reload
[Scenario 10](output/10-list-order-key-column.txt): items saved in the order `[SKU-Z, SKU-A,
SKU-M]` come back in that same order after a reload, because `@MappedCollection(idColumn =
"ORDER_ID", keyColumn = "ORDER_KEY")` adds an extra integer column that records list position.
Omit `keyColumn` and Spring Data JDBC still stores a `List` correctly — it just does not
guarantee you the order back. On a fresh H2 table you will likely get insertion order anyway,
which is exactly the kind of accident that stops being true the day someone runs a `VACUUM`, or
the day the database changes.
## Optimistic locking works the same way it does under JPA
[Scenario 09](output/09-optimistic-locking.txt): two callers load the same `version`, the first
saves (version increments), the second — still holding the stale version — tries to save and
gets `OptimisticLockingFailureException`. `@Version` is the same annotation
(`org.springframework.data.annotation.Version`) doing conceptually the same thing: the UPDATE's
`WHERE` clause includes the expected version, zero rows match a stale one, and Spring Data JDBC
turns "zero rows updated" into an exception rather than silently doing nothing.
Continue to [05 — the aggregate boundary](05-the-aggregate-boundary.md).
@@ -0,0 +1,38 @@
# 05 — The aggregate boundary
[← 04 the JDBC side](04-the-jdbc-side.md) · [next: when this gets expensive →](06-when-this-gets-expensive.md)
Source: [`jdbc/Order.java`](../src/main/java/com/ankurm/jdbcvsjpa/jdbc/Order.java) (the
`AggregateReference<Customer, Long> customer` field). Test:
[`JdbcBehaviorTest#c_aggregateReferenceDoesNotLoadTheCustomer`](../src/test/java/com/ankurm/jdbcvsjpa/JdbcBehaviorTest.java),
transcript [08](output/08-aggregate-reference-no-join.txt).
Domain-Driven Design's aggregate pattern says: an aggregate is a cluster of objects saved and
loaded as one transactional unit, and a reference to *another* aggregate is held by identity, not
by object graph. Spring Data JDBC enforces this at the type level. `Order.customer` is not a
`Customer` field — it is an `AggregateReference<Customer, Long>`, a typed wrapper around a
foreign-key value. `AggregateReference.getId()` returns that value without touching the database,
because the value is already sitting in a column that was read as part of loading the order.
[Scenario 08](output/08-aggregate-reference-no-join.txt) demonstrates the consequence directly:
loading an order and then reading `order.getCustomer().getId()` produces **zero** statements
touching the customer table. There is no join, no lazy proxy, no N+1 waiting to happen — because
there is no mechanism in Spring Data JDBC that would ever load a second aggregate as a side
effect of loading the first one. If you want the `Customer`, you ask a `CustomerRepository` for
it, explicitly, and that is a second, deliberate query.
This is the mechanism that makes [scenario 06](04-the-jdbc-side.md#findbyid-always-returns-the-whole-aggregate)'s
"fixed cost regardless of item count" claim scale past one aggregate: the fixed cost is fixed
*per aggregate*, and nothing about loading `Order` #1 ever cascades into loading `Order` #2's
`Customer`, `Order` #2's `Customer`'s other orders, and so on. JPA's `@ManyToOne(fetch = LAZY)`
gives you a proxy that resolves this the moment something touches it — convenient until the thing
touching it is a `toString()` in a log statement three services away from the code that loaded
the entity.
The trade is that Spring Data JDBC will not silently fetch related data across an aggregate
boundary for you, ever, under any circumstance. If your domain genuinely needs a graph fetch
(here's every order together with its customer's other orders), you write that query yourself.
Some teams experience that as "more code to write." Others experience the JPA alternative as "a
query plan I can no longer predict by reading the entity class."
Continue to [06 — when this gets expensive](06-when-this-gets-expensive.md).
@@ -0,0 +1,53 @@
# 06 — When this gets expensive (production checklist)
[← 05 the aggregate boundary](05-the-aggregate-boundary.md)
A short, honest checklist for deciding between the two stacks on a real project, expanding on the
article's closing "should you even do this" callout.
## Reach for Spring Data JDBC when
- Your aggregates are genuinely small (single digits to low tens of child rows). The
delete-and-reinsert cost in [07](output/07-delete-then-insert.txt) is proportional to
aggregate size on every save, not to what changed.
- You have been bitten by `LazyInitializationException`, N+1 queries you didn't predict, or a
flush timing surprise (scenarios [01](output/01-lazy-outside-session.txt),
[02](output/02-n-plus-one.txt), [04](output/04-dirty-checking-autoflush.txt)) more than once on
the same codebase.
- You want the query plan for "load this thing" to be readable from the entity class alone, with
no session state, no `@EntityGraph` annotations to remember, and no proxy that decides at
runtime whether to hit the database.
- You are comfortable writing more explicit queries (`@Query`, `JdbcAggregateTemplate`) for
anything that crosses an aggregate boundary, because that boundary is enforced rather than
advisory.
## Stay on Spring Data JPA when
- Collections are large or unbounded (hundreds to thousands of child rows) and updated
incrementally — the full delete-and-reinsert becomes real, measurable write amplification.
- Your domain genuinely benefits from a persistence-context-managed object graph: complex
bidirectional relationships, inheritance hierarchies, or second-level caching.
- Your team already has deep Hibernate operational experience (query plan reading, N+1 detection
tooling, `@BatchSize` tuning) and the tooling investment already exists.
- You need JPQL/Criteria API's richer query composition, or a library that specifically expects
`EntityManager`.
## What migrating does NOT require
You do not have to choose one stack for a whole application. This module runs both in one Spring
context (`@EnableJpaRepositories` scoped to one package, `@EnableJdbcRepositories` scoped to
another — see [`JdbcVsJpaApplication.java`](../src/main/java/com/ankurm/jdbcvsjpa/JdbcVsJpaApplication.java)).
A pragmatic migration moves the aggregates that are small, hot, and bug-prone under JPA's
lazy-loading surprises first, and leaves the genuinely graph-shaped, cache-heavy parts of the
domain on JPA.
## What to measure before deciding, on your own schema
1. p95 aggregate size (row count) for the collections you're considering moving.
2. Write frequency versus read frequency for those aggregates — JDBC's replace-on-save cost is a
write-path cost; if reads dominate, it may not matter.
3. Whether any code today relies on partial, incremental updates to a large collection
(`@Query` bulk updates exist on both sides, but the ORM-managed dirty-checking path some teams
lean on for this does not exist under Spring Data JDBC at all).
[← back to the shared domain](01-the-shared-domain.md) · [README](../README.md)
@@ -0,0 +1,16 @@
Same logical operation — load every order for a customer and read every item on
every order — run against identical H2 data, counted by the same
StatementLoggingDataSource, for two stacks.
orders items/ea JPA (no fetch join) Spring Data JDBC
1 1 2 2
1 5 2 2
5 1 6 6
5 5 6 6
20 3 21 21
JPA here uses plain findAll() + lazy .getItems() (scenario 02's shape); adding
an @EntityGraph flattens the JPA column to a constant too (scenario 03). The
point is not "JDBC beats JPA" in the abstract — it's that JDBC's default gives
you the flattened shape for free, while JPA's default gives you the linear one,
and the fix for JPA requires the reader to know it's needed.
@@ -0,0 +1,8 @@
Scenario: load an Order inside a transaction, return it, then touch the lazy
items collection AFTER the transaction (and its Hibernate session) has closed.
order.getItems().size() -> org.hibernate.LazyInitializationException: Cannot lazily initialize collection of role 'com.ankurm.jdbcvsjpa.jpa.Order.items' with key '34' (no session)
This is the LazyInitializationException every JPA tutorial warns about and few
show you actually triggering. It is not a bug: the collection genuinely has not
been read from the database yet, and the session that could read it is gone.
+13
View File
@@ -0,0 +1,13 @@
Scenario: findAll() for 3 orders (2 items each), then read .getItems().size()
on each in a loop — the pattern that looks completely ordinary in a service
method.
1. select o1_0.id,o1_0.customer_id,o1_0.version from jpa_order o1_0
2. select i1_0.order_id,i1_0.id,i1_0.quantity,i1_0.sku from jpa_order_item i1_0 where i1_0.order_id=?
3. select i1_0.order_id,i1_0.id,i1_0.quantity,i1_0.sku from jpa_order_item i1_0 where i1_0.order_id=?
4. select i1_0.order_id,i1_0.id,i1_0.quantity,i1_0.sku from jpa_order_item i1_0 where i1_0.order_id=?
total statements: 4
SELECT statements observed: 4 (1 for the orders themselves + 1 per order for
its lazily-loaded items = N+1, here 1 + 3 = 4)
@@ -0,0 +1,10 @@
Scenario: the same lookup, but through findWithItemsAndCustomerById (an
@EntityGraph fetch), for an order with 1 item and an order with 5 items.
statements for 1-item order: 1
statements for 5-item order: 1
Both are the same number — an entity graph turns the collection fetch into a
single outer-join SELECT, so the statement count stops depending on item count.
This is the fix for scenario 02, and it is also the shape Spring Data JDBC gives
you by default with no annotation at all (see docs/05).
@@ -0,0 +1,17 @@
Scenario: inside one transaction, load an order, mutate a managed item's
quantity field directly (no repository.save() call), then run an unrelated
query (customerRepository.count()) in the same transaction.
1. select o1_0.id,c1_0.id,c1_0.name,i1_0.order_id,i1_0.id,i1_0.quantity,i1_0.sku,o1_0.version from jpa_order o1_0 left join jpa_customer c1_0 on c1_0.id=o1_0.customer_id left join jpa_order_item i1_0 on o1_0.id=i1_0.order_id where o1_0.id=?
2. select count(*) from jpa_customer c1_0
3. update jpa_order_item set order_id=?,quantity=?,sku=? where id=?
total statements: 3
An UPDATE for jpa_order_item appears BEFORE the transaction ever commits and
without save() ever being called, because Hibernate's dirty checking flushes
pending changes ahead of any query that could otherwise see stale data. This is
correct behaviour, but it means "no save() call" does not mean "no write" — see
docs/02-the-jpa-side.md for the case where this surprises people (an outer
@Transactional method that throws after this point still writes the row before
the eventual rollback un-writes it).
@@ -0,0 +1,11 @@
Scenario: remove one item from order.items (a plain List.remove, via the
addItem/removeItem helpers that keep both sides in sync) and save() the order.
1. select o1_0.id,c1_0.id,c1_0.name,i1_0.order_id,i1_0.id,i1_0.quantity,i1_0.sku,o1_0.version from jpa_order o1_0 left join jpa_customer c1_0 on c1_0.id=o1_0.customer_id left join jpa_order_item i1_0 on o1_0.id=i1_0.order_id where o1_0.id=?
2. delete from jpa_order_item where id=?
total statements: 2
orphanRemoval = true on the @OneToMany turns "no longer in the collection" into
a DELETE for that row. Without orphanRemoval, the row survives with a dangling
(nulled or stale) order_id, which is the more common real-world bug report.
@@ -0,0 +1,10 @@
Scenario: findById on an order with 1 item, then on an order with 5 items — no
@EntityGraph, no fetch annotation, this is the default and only behaviour.
statements for 1-item order: 2 (items loaded: 1)
statements for 5-item order: 2 (items loaded: 5)
Both counts are the same. There is no lazy vs eager choice to make because
Spring Data JDBC has no lazy loading: findById always returns the complete
aggregate. Compare to JpaBehaviorTest scenario 02, where the naive path is O(N)
in the number of orders touched, not items.
@@ -0,0 +1,25 @@
Scenario: change the quantity of exactly ONE of 3 order items, then save() the
whole aggregate — the idiomatic Spring Data JDBC pattern (see Order#withItems).
1. UPDATE "JDBC_ORDER" SET "VERSION" = ?, "CUSTOMER_ID" = ? WHERE "JDBC_ORDER"."ID" = ? AND "JDBC_ORDER"."VERSION" = ?
2. DELETE FROM "JDBC_ORDER_ITEM" WHERE "JDBC_ORDER_ITEM"."ORDER_ID" = ?
3. INSERT INTO "JDBC_ORDER_ITEM" ("ORDER_ID", "ORDER_KEY", "ID", "QUANTITY", "SKU") VALUES (?, ?, ?, ?, ?)
4. SELECT "JDBC_ORDER"."ID" AS "ID", "JDBC_ORDER"."VERSION" AS "VERSION", "JDBC_ORDER"."CUSTOMER_ID" AS "CUSTOMER_ID" FROM "JDBC_ORDER" WHERE "JDBC_ORDER"."ID" = ?
5. SELECT "JDBC_ORDER_ITEM"."ID" AS "ID", "JDBC_ORDER_ITEM"."SKU" AS "SKU", "JDBC_ORDER_ITEM"."QUANTITY" AS "QUANTITY", "JDBC_ORDER_ITEM"."ORDER_KEY" AS "ORDER_KEY" FROM "JDBC_ORDER_ITEM" WHERE "JDBC_ORDER_ITEM"."ORDER_ID" = ? ORDER BY "ORDER_KEY"
total statements: 5
DELETE statements: 1
INSERT statements: 1 (but the reload shows 3 rows back: [OrderItem[id=13, sku=SKU-1, quantity=1], OrderItem[id=15, sku=SKU-3, quantity=3], OrderItem[id=14, sku=SKU-2, quantity=999]])
Spring Data JDBC does not diff the collection: saving an aggregate root with a
@MappedCollection deletes every existing child row for that parent and reinserts
the current collection from scratch, every time — even when only one of three
rows actually changed. What it does NOT do is pay for that with N round trips:
the three inserts above are one JDBC *batch* — addBatch() three times,
executeBatch() once — so this shows as a single "INSERT" line in the log, not
three. This is the real trade the "dropping the ORM" article leads with: you
give up field-level dirty checking for the aggregate model, but the replace is
a delete plus one batched insert, not delete-plus-N-inserts. For a collection
with thousands of rows the row churn is still real (every row is rewritten, not
just the changed one); see docs/06-when-this-gets-expensive.md.
@@ -0,0 +1,17 @@
Scenario: load an order, then read its customer's id through the
AggregateReference — never call customerRepository.findById for it.
1. SELECT "JDBC_ORDER"."ID" AS "ID", "JDBC_ORDER"."VERSION" AS "VERSION", "JDBC_ORDER"."CUSTOMER_ID" AS "CUSTOMER_ID" FROM "JDBC_ORDER" WHERE "JDBC_ORDER"."ID" = ?
2. SELECT "JDBC_ORDER_ITEM"."ID" AS "ID", "JDBC_ORDER_ITEM"."SKU" AS "SKU", "JDBC_ORDER_ITEM"."QUANTITY" AS "QUANTITY", "JDBC_ORDER_ITEM"."ORDER_KEY" AS "ORDER_KEY" FROM "JDBC_ORDER_ITEM" WHERE "JDBC_ORDER_ITEM"."ORDER_ID" = ? ORDER BY "ORDER_KEY"
total statements: 2
statements touching jdbc_customer: 0
customer id obtained without a lookup: 3
AggregateReference<Customer, Long> is a typed foreign key: getId() is free
because the id is literally the column value already in hand from loading the
order. Nothing about Customer is fetched unless you explicitly ask a
CustomerRepository for it. This is the mechanism that keeps a multi-aggregate
object graph from becoming N+1 by default — there is no "default" traversal at
all across an aggregate boundary.
@@ -0,0 +1,9 @@
Scenario: two callers load the same order (both see version N), the first saves
(version becomes N+1), then the second — still holding version N — tries to save.
result: org.springframework.dao.OptimisticLockingFailureException: Failed to update versioned entity with id '1' (version '0') in table ["JDBC_ORDER"]; Was the entity updated or deleted concurrently?
@Version on the JDBC side behaves the same as @Version under JPA: the UPDATE's
WHERE clause includes "and version = :expectedVersion", zero rows match, and
Spring Data JDBC turns that into OptimisticLockingFailureException rather than
silently doing nothing.
@@ -0,0 +1,11 @@
Scenario: save items in the order [SKU-Z, SKU-A, SKU-M] (deliberately not
alphabetical), reload, and read the list back.
reloaded order: [SKU-Z, SKU-A, SKU-M]
@MappedCollection(idColumn = "order_id", keyColumn = "order_key") is what makes
this deterministic. Without keyColumn, Spring Data JDBC still stores a List
correctly, but the reload order is whatever the database happens to return —
usually insertion order on a fresh H2 table, but that is an implementation
detail, not a guarantee, and it is the kind of thing that breaks quietly on a
different database or after a compaction.