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.
jdbc-vs-jpa
Companion code for Spring Data JDBC vs Spring Data JPA in 2026: When Dropping the ORM Is the Right Call on ankurm.com.
The same Customer → Order → OrderItem domain, modelled twice — once with Hibernate/Spring
Data JPA (com.ankurm.jdbcvsjpa.jpa), once with Spring Data JDBC
(com.ankurm.jdbcvsjpa.jdbc) — running against the same H2 database through the same
StatementLoggingDataSource,
so every SQL-statement count quoted in the article is a fair, apples-to-apples measurement, not
two separate benchmarks run under different conditions.
Tested with: Spring Boot 4.1.1 / Spring Framework 7.0.9 / Spring Data JDBC 4.1.1 / Spring Data JPA (Hibernate ORM) via the Boot 4.1.1 BOM / H2 2.x / JDK 25 (Temurin 25.0.4.1).
Quickstart
./mvnw test # runs everything, regenerates docs/output/
./mvnw spring-boot:run # then: curl localhost:8080/diag/sql-log
Where things are
| Shared domain | docs/01-the-shared-domain.md |
| JPA side | docs/02-the-jpa-side.md — src/main/java/.../jpa/ |
| The SQL log mechanism | docs/03-the-sql-log.md — src/main/java/.../support/ |
| JDBC side | docs/04-the-jdbc-side.md — src/main/java/.../jdbc/ |
| The aggregate boundary | docs/05-the-aggregate-boundary.md |
| Production checklist | docs/06-when-this-gets-expensive.md |
Captured output
Every number quoted in the article is one of these files, regenerated by ./mvnw test:
| File | What it shows |
|---|---|
| docs/output/00-statement-count-comparison.txt | The headline table: statement counts, JPA vs JDBC, across order/item counts |
| docs/output/01-lazy-outside-session.txt | LazyInitializationException triggered for real |
| docs/output/02-n-plus-one.txt | N+1 from a plain findAll() + loop |
| docs/output/03-entity-graph-fixed-cost.txt | @EntityGraph flattening the cost to a constant |
| docs/output/04-dirty-checking-autoflush.txt | An UPDATE with no save() call anywhere |
| docs/output/05-orphan-removal-delete.txt | orphanRemoval turning a list removal into a DELETE |
| docs/output/06-fixed-cost-load.txt | Spring Data JDBC's fixed-statement-count findById |
| docs/output/07-delete-then-insert.txt | The delete-then-batched-insert collection replace |
| docs/output/08-aggregate-reference-no-join.txt | AggregateReference costing zero extra statements |
| docs/output/09-optimistic-locking.txt | @Version conflict on both stacks |
| docs/output/10-list-order-key-column.txt | keyColumn making list order deterministic |
Diagnostic endpoint
GET /diag/sql-log dumps every statement executed in this JVM since the last reset — the same
mechanism the tests use. Delete DiagController before shipping; it is a demo aid, not a
feature.