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:
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user