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,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)