Files
asmhatre 448c383879 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.
2026-09-17 19:09:35 +00:00

48 lines
3.1 KiB
Markdown

# 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).