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
@@ -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.