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
+39 -14
View File
@@ -2,16 +2,17 @@
Companion code for the Spring Data JPA and transaction articles on **[ankurm.com](https://ankurm.com)**. Companion code for the Spring Data JPA and transaction articles on **[ankurm.com](https://ankurm.com)**.
Two independent Maven modules under one aggregator: Three independent Maven modules under one aggregator:
| Module | Article(s) | Boot / JDK | | Module | Article(s) | Boot / JDK |
|---|---|---| |---|---|---|
| [`migration-behavior/`](migration-behavior) | the four Spring Data JPA 3→4 migration articles | 4.0.6 / 21 | | [`migration-behavior/`](migration-behavior) | the four Spring Data JPA 34 migration articles | 4.0.6 / 21 |
| [`transactions/`](transactions) | [@Transactional: propagation, isolation and the six silent failures](https://ankurm.com/) | 4.1.1 / 25 | | [`transactions/`](transactions) | [@Transactional: propagation, isolation and the six silent failures](https://ankurm.com/) | 4.1.1 / 25 |
| [`jdbc-vs-jpa/`](jdbc-vs-jpa) | [Spring Data JDBC vs Spring Data JPA in 2026: When Dropping the ORM Is the Right Call](https://ankurm.com/) | 4.1.1 / 25 |
The modules deliberately pin different Spring Boot versions. `migration-behavior` stays on The modules deliberately pin different Spring Boot versions. `migration-behavior` stays on 4.0.6
4.0.6 because that is what the four published migration articles were written and verified because that is what the four published migration articles were written and verified against,
against, and upgrading it would silently invalidate output those articles quote. and upgrading it would silently invalidate output those articles quote.
> **Moved in September 2026.** The migration project used to live at the repository root. It is > **Moved in September 2026.** The migration project used to live at the repository root. It is
> now under `migration-behavior/`; source paths gained that prefix and nothing else changed. The > now under `migration-behavior/`; source paths gained that prefix and nothing else changed. The
@@ -19,20 +20,44 @@ against, and upgrading it would silently invalidate output those articles quote.
> original layout, so a link into a tagged tree is unaffected. > original layout, so a link into a tagged tree is unaffected.
```bash ```bash
./mvnw -DskipTests package # both modules ./mvnw -DskipTests package # all modules
./mvnw test # every test in both ./mvnw test # every test in all three
``` ```
## Tags ## Tags
- `article-1-baseline`, `article-2-query-engine`, `article-3-advanced` - the exact code each article quotes, frozen at publish time. - `article-1-baseline`, `article-2-query-engine`, `article-3-advanced` - the exact code each
- `corner-scenarios` (and `main`) - the enriched, current state described below. Some method signatures have moved on from the article-tagged snapshots (e.g. `Book`'s `price` is now an embedded `Money` value object, not a bare `BigDecimal`), so check out the matching article tag if you want the code to line up exactly with what's quoted in a given post. migration article quotes, frozen at publish time.
- `corner-scenarios` (and `main`) - the enriched, current state described below. Some method
signatures have moved on from the article-tagged snapshots (e.g. `Book`'s `price` is now an
embedded `Money` value object, not a bare `BigDecimal`), so check out the matching article tag
if you want the code to line up exactly with what's quoted in a given post.
## What's covered beyond the three articles (corner-scenario enrichment) ## What's covered beyond the three articles (corner-scenario enrichment)
- **Refined Specification API** (`AuthorSpecifications.java`): `PredicateSpecification` reused across a read and a bulk delete, an explicit `DeleteSpecification` (`CriteriaDelete`-backed), and an `UpdateSpecification` (`CriteriaUpdate`-backed bulk update composed from an `UpdateOperation` + a `where(...)` predicate). - **Refined Specification API** (`AuthorSpecifications.java`): `PredicateSpecification` reused
- **`JpaSort.unsafe(...)` with a `CASE` expression** - a real `ORDER BY case when country = 'US' then 0 else 1 end` sort combined with a plain derived query. across a read and a bulk delete, an explicit `DeleteSpecification` (`CriteriaDelete`-backed),
- **`Money`, an `@Embeddable` record value object** on `Book.price`, with derived queries that traverse the embedded path (`findByPriceAmountGreaterThanEqual`, `findByPriceAmount`). and an `UpdateSpecification` (`CriteriaUpdate`-backed bulk update composed from an
- **A genuine corner case, found by actually running it**: a derived-query class-based (record) projection resolves constructor-parameter names against *direct* entity properties only. `BookSummary(String title, BigDecimal amount)` does **not** resolve `amount` against the nested `price.amount` path via a plain `findBy...` derived method - it throws `PropertyReferenceException: No property 'amount' found for type 'Book'`. The fix is an explicit `@Query` constructor expression (`select new ...BookSummary(b.title, b.price.amount) from Book b where ...`), which does work for nested/embedded paths. See `BookRepository.findByPriceAmountLessThanEqual`. `UpdateOperation` + a `where(...)` predicate).
- **`JpaSort.unsafe(...)` with a `CASE` expression** - a real
`ORDER BY case when country = 'US' then 0 else 1 end` sort combined with a plain derived query.
- **`Money`, an `@Embeddable` record** value object on `Book.price`, with derived queries that
traverse the embedded path (`findByPriceAmountGreaterThanEqual`, `findByPriceAmount`).
- **A genuine corner case, found by actually running it**: a derived-query class-based (record)
projection resolves constructor-parameter names against *direct* entity properties only.
`BookSummary(String title, BigDecimal amount)` does **not** resolve `amount` against the nested
`price.amount` path via a plain `findBy...` derived method - it throws
`PropertyReferenceException: No property 'amount' found for type 'Book'`. The fix is an
explicit `@Query` constructor expression
(`select new ...BookSummary(b.title, b.price.amount) from Book b where ...`), which does handle
nested/embedded paths. See `BookRepository.findByPriceAmountLessThanEqual`.
All of the above is exercised by both the demo runner (`DemoRunner.java`, sections H-L) and dedicated tests in `MigrationBehaviorTests.java`. All of the above is exercised by both the demo runner (`DemoRunner.java`, sections H-L) and
dedicated tests in `MigrationBehaviorTests.java`.
## jdbc-vs-jpa
Same `Customer`/`Order`/`OrderItem` domain modelled twice - Hibernate/Spring Data JPA and Spring
Data JDBC - against the same H2 database through the same statement-logging `DataSource`, so the
SQL-statement counts quoted in the article are a fair, apples-to-apples comparison. See
[`jdbc-vs-jpa/README.md`](jdbc-vs-jpa/README.md).
+56
View File
@@ -0,0 +1,56 @@
# jdbc-vs-jpa
Companion code for **[Spring Data JDBC vs Spring Data JPA in 2026: When Dropping the ORM Is the
Right Call](https://ankurm.com/)** on [ankurm.com](https://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`](src/main/java/com/ankurm/jdbcvsjpa/support/StatementLoggingDataSource.java),
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
```bash
./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](docs/01-the-shared-domain.md) |
| JPA side | [docs/02-the-jpa-side.md](docs/02-the-jpa-side.md) — `src/main/java/.../jpa/` |
| The SQL log mechanism | [docs/03-the-sql-log.md](docs/03-the-sql-log.md) — `src/main/java/.../support/` |
| JDBC side | [docs/04-the-jdbc-side.md](docs/04-the-jdbc-side.md) — `src/main/java/.../jdbc/` |
| The aggregate boundary | [docs/05-the-aggregate-boundary.md](docs/05-the-aggregate-boundary.md) |
| Production checklist | [docs/06-when-this-gets-expensive.md](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](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](docs/output/01-lazy-outside-session.txt) | `LazyInitializationException` triggered for real |
| [docs/output/02-n-plus-one.txt](docs/output/02-n-plus-one.txt) | N+1 from a plain `findAll()` + loop |
| [docs/output/03-entity-graph-fixed-cost.txt](docs/output/03-entity-graph-fixed-cost.txt) | `@EntityGraph` flattening the cost to a constant |
| [docs/output/04-dirty-checking-autoflush.txt](docs/output/04-dirty-checking-autoflush.txt) | An UPDATE with no `save()` call anywhere |
| [docs/output/05-orphan-removal-delete.txt](docs/output/05-orphan-removal-delete.txt) | `orphanRemoval` turning a list removal into a DELETE |
| [docs/output/06-fixed-cost-load.txt](docs/output/06-fixed-cost-load.txt) | Spring Data JDBC's fixed-statement-count `findById` |
| [docs/output/07-delete-then-insert.txt](docs/output/07-delete-then-insert.txt) | The delete-then-batched-insert collection replace |
| [docs/output/08-aggregate-reference-no-join.txt](docs/output/08-aggregate-reference-no-join.txt) | `AggregateReference` costing zero extra statements |
| [docs/output/09-optimistic-locking.txt](docs/output/09-optimistic-locking.txt) | `@Version` conflict on both stacks |
| [docs/output/10-list-order-key-column.txt](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.
+37
View File
@@ -0,0 +1,37 @@
# 01 — The shared domain
[← README](../README.md) · [next: the JPA side →](02-the-jpa-side.md)
Both stacks in this module model the same thing: a `Customer` who places `Order`s, each with a
list of `OrderItem`s. It is deliberately the smallest domain that has everything worth arguing
about — a one-to-many collection, a reference to a second aggregate, and a field a reader will
want to mutate in place.
| Concept | JPA package (`com.ankurm.jdbcvsjpa.jpa`) | JDBC package (`com.ankurm.jdbcvsjpa.jdbc`) |
|---|---|---|
| Table prefix | `jpa_*` | `JDBC_*` (see the identifier-casing note below) |
| Order → Customer | `@ManyToOne(fetch = LAZY)` object reference | `AggregateReference<Customer, Long>` — a typed foreign key, never a loaded object |
| Order → items | `@OneToMany(mappedBy, cascade = ALL, orphanRemoval = true)`, `List<OrderItem>` | `@MappedCollection(idColumn, keyColumn)`, `List<OrderItem>` |
| Identity of a child row | `@ManyToOne` back-reference to its parent | none — a JDBC `OrderItem` has no idea which Order owns it |
| Optimistic locking | `@Version Long version` | `@Version Long version` (same annotation, same package: `org.springframework.data.annotation`) |
Both sides run against the **same H2 database** through the **same
[`StatementLoggingDataSource`](../src/main/java/com/ankurm/jdbcvsjpa/support/StatementLoggingDataSource.java)**
— see [03 — the SQL log](03-the-sql-log.md) for why that matters for the numbers in the article.
## A casing trap that cost a debugging session
Spring Data JDBC treats an explicit `@Table("some_name")` or `@Column("some_name")` value as a
*literal, quoted* SQL identifier — it is rendered exactly as given, in double quotes. Properties
with no explicit annotation go through the H2 dialect's default identifier processing instead,
which upper-cases them. H2 itself upper-cases any *unquoted* identifier in DDL. The result: an
unquoted `create table jdbc_customer(...)` in `schema.sql` produces a table H2 privately calls
`JDBC_CUSTOMER`, but `@Table("jdbc_customer")` generates `INSERT INTO "jdbc_customer" (...)`
quoted, lower-case, and therefore a different identifier as far as H2 is concerned. The fix used
throughout this module is to give every explicit `@Table`/`@Column`/`@MappedCollection` value in
upper case, matching H2's own folding, so the literal and the folded name agree. See
[`jdbc/Order.java`](../src/main/java/com/ankurm/jdbcvsjpa/jdbc/Order.java) for the annotations
and [`schema.sql`](../src/main/resources/schema.sql) for the DDL. This is exactly the kind of
thing that never shows up in a tutorial that only ever runs one save and eyeballs the console.
Continue to [02 — the JPA side](02-the-jpa-side.md).
+47
View File
@@ -0,0 +1,47 @@
# 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).
+48
View File
@@ -0,0 +1,48 @@
# 03 — The SQL log
[← 02 the JPA side](02-the-jpa-side.md) · [next: the JDBC side →](04-the-jdbc-side.md)
Source: [`support/StatementLoggingDataSource.java`](../src/main/java/com/ankurm/jdbcvsjpa/support/StatementLoggingDataSource.java),
[`support/SqlLog.java`](../src/main/java/com/ankurm/jdbcvsjpa/support/SqlLog.java). Endpoint:
[`DiagController.java`](../src/main/java/com/ankurm/jdbcvsjpa/DiagController.java) at `/diag/sql-log`.
Every statement-count claim in this article comes from one mechanism, not from reading
Hibernate's `show_sql` output in one format and Spring Data JDBC's `JdbcTemplate` logging in a
different one. `StatementLoggingDataSource` wraps the single H2 `DataSource` both stacks share:
it hands out a JDK dynamic proxy for every `Connection`, which in turn hands out a proxy for
every `Statement`/`PreparedStatement`, and any method starting with `execute` gets its SQL text
recorded to `SqlLog` before the call is delegated. This is deliberately *below* both ORMs — it
counts what actually reached the database driver, not what each framework's own debug logging
chose to print.
```java
@Bean
public DataSource dataSource(SqlLog sqlLog) {
HikariDataSource real = new HikariDataSource();
real.setJdbcUrl("jdbc:h2:mem:jdbcvsjpa;DB_CLOSE_DELAY=-1;MODE=LEGACY");
// ...
return new StatementLoggingDataSource(real, sqlLog);
}
```
Two things this caught that a naive count would have missed:
- **JDBC batching.** Saving three `OrderItem` rows as part of one aggregate save
(see [07](output/07-delete-then-insert.txt)) shows up as *one* `INSERT` line, not three —
Spring Data JDBC calls `addBatch()` three times and `executeBatch()` once. Counting SQL text
seen by `Statement.executeQuery`/`executeUpdate` would have reported 3 inserts; counting actual
`execute*` invocations on the proxy correctly reports 1, because that is the true number of
round trips to the database.
- **Consistent counting across two completely different SQL-generation paths.** Hibernate's HQL
compiler and Spring Data JDBC's `JdbcTemplate`-based query building produce differently
formatted SQL for the same logical operation (see the JPA transcripts' lower-case, unquoted
style versus the JDBC transcripts' upper-case, quoted style — both are the frameworks' own
defaults, untouched). A statement counter that lived inside either framework would only ever
see its own side; this one sees both, so [00 — the comparison table](output/00-statement-count-comparison.txt)
is a fair, apples-to-apples number.
`/diag/sql-log` exposes the same log at runtime for manual exploration — hit an endpoint, then
`curl localhost:8080/diag/sql-log` to see exactly what ran. Delete this controller before
shipping; it has no business existing outside a demo.
Continue to [04 — the JDBC side](04-the-jdbc-side.md).
+50
View File
@@ -0,0 +1,50 @@
# 04 — The JDBC side
[← 03 the SQL log](03-the-sql-log.md) · [next: the aggregate boundary →](05-the-aggregate-boundary.md)
Source: [`jdbc/Order.java`](../src/main/java/com/ankurm/jdbcvsjpa/jdbc/Order.java),
[`jdbc/OrderItem.java`](../src/main/java/com/ankurm/jdbcvsjpa/jdbc/OrderItem.java),
[`jdbc/Customer.java`](../src/main/java/com/ankurm/jdbcvsjpa/jdbc/Customer.java). Tests:
[`JdbcBehaviorTest.java`](../src/test/java/com/ankurm/jdbcvsjpa/JdbcBehaviorTest.java).
## findById always returns the whole aggregate
[Scenario 06](output/06-fixed-cost-load.txt): `findById` on a 1-item order and on a 5-item order
both take exactly 2 statements (one for the order row, one for its items, via a join-free
two-query load — see the actual SQL in the transcript). There is no lazy/eager choice to make
because there is no lazy loading. This is not an optimization Spring Data JDBC performs; it is
the *only* thing it knows how to do, because an aggregate is, by definition, loaded and saved as
one unit.
## Saving replaces the whole collection — but as one batch
[Scenario 07](output/07-delete-then-insert.txt): changing the quantity on exactly one of three
`OrderItem`s and saving the aggregate produces a `DELETE ... WHERE order_id = ?` for all three
existing rows and then a *single* batched `INSERT` (three rows, one JDBC batch execution — see
[03](03-the-sql-log.md) for how that's verified). Spring Data JDBC does not diff collections; it
does not need to know which row changed, because it never tries to compute a diff. The cost is
real (every row's data is retransmitted and reinserted, not just the one that changed) but it is
one delete and one batch insert, not `N+1` round trips. For a collection in the tens of rows this
is invisible. For a collection in the tens of thousands, see
[06 — when this gets expensive](06-when-this-gets-expensive.md).
## `keyColumn` is what makes list order survive a reload
[Scenario 10](output/10-list-order-key-column.txt): items saved in the order `[SKU-Z, SKU-A,
SKU-M]` come back in that same order after a reload, because `@MappedCollection(idColumn =
"ORDER_ID", keyColumn = "ORDER_KEY")` adds an extra integer column that records list position.
Omit `keyColumn` and Spring Data JDBC still stores a `List` correctly — it just does not
guarantee you the order back. On a fresh H2 table you will likely get insertion order anyway,
which is exactly the kind of accident that stops being true the day someone runs a `VACUUM`, or
the day the database changes.
## Optimistic locking works the same way it does under JPA
[Scenario 09](output/09-optimistic-locking.txt): two callers load the same `version`, the first
saves (version increments), the second — still holding the stale version — tries to save and
gets `OptimisticLockingFailureException`. `@Version` is the same annotation
(`org.springframework.data.annotation.Version`) doing conceptually the same thing: the UPDATE's
`WHERE` clause includes the expected version, zero rows match a stale one, and Spring Data JDBC
turns "zero rows updated" into an exception rather than silently doing nothing.
Continue to [05 — the aggregate boundary](05-the-aggregate-boundary.md).
@@ -0,0 +1,38 @@
# 05 — The aggregate boundary
[← 04 the JDBC side](04-the-jdbc-side.md) · [next: when this gets expensive →](06-when-this-gets-expensive.md)
Source: [`jdbc/Order.java`](../src/main/java/com/ankurm/jdbcvsjpa/jdbc/Order.java) (the
`AggregateReference<Customer, Long> customer` field). Test:
[`JdbcBehaviorTest#c_aggregateReferenceDoesNotLoadTheCustomer`](../src/test/java/com/ankurm/jdbcvsjpa/JdbcBehaviorTest.java),
transcript [08](output/08-aggregate-reference-no-join.txt).
Domain-Driven Design's aggregate pattern says: an aggregate is a cluster of objects saved and
loaded as one transactional unit, and a reference to *another* aggregate is held by identity, not
by object graph. Spring Data JDBC enforces this at the type level. `Order.customer` is not a
`Customer` field — it is an `AggregateReference<Customer, Long>`, a typed wrapper around a
foreign-key value. `AggregateReference.getId()` returns that value without touching the database,
because the value is already sitting in a column that was read as part of loading the order.
[Scenario 08](output/08-aggregate-reference-no-join.txt) demonstrates the consequence directly:
loading an order and then reading `order.getCustomer().getId()` produces **zero** statements
touching the customer table. There is no join, no lazy proxy, no N+1 waiting to happen — because
there is no mechanism in Spring Data JDBC that would ever load a second aggregate as a side
effect of loading the first one. If you want the `Customer`, you ask a `CustomerRepository` for
it, explicitly, and that is a second, deliberate query.
This is the mechanism that makes [scenario 06](04-the-jdbc-side.md#findbyid-always-returns-the-whole-aggregate)'s
"fixed cost regardless of item count" claim scale past one aggregate: the fixed cost is fixed
*per aggregate*, and nothing about loading `Order` #1 ever cascades into loading `Order` #2's
`Customer`, `Order` #2's `Customer`'s other orders, and so on. JPA's `@ManyToOne(fetch = LAZY)`
gives you a proxy that resolves this the moment something touches it — convenient until the thing
touching it is a `toString()` in a log statement three services away from the code that loaded
the entity.
The trade is that Spring Data JDBC will not silently fetch related data across an aggregate
boundary for you, ever, under any circumstance. If your domain genuinely needs a graph fetch
(here's every order together with its customer's other orders), you write that query yourself.
Some teams experience that as "more code to write." Others experience the JPA alternative as "a
query plan I can no longer predict by reading the entity class."
Continue to [06 — when this gets expensive](06-when-this-gets-expensive.md).
@@ -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)
@@ -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.
+64
View File
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>jdbc-vs-jpa</artifactId>
<version>1.0.0</version>
<name>jdbc-vs-jpa</name>
<description>Spring Data JDBC vs Spring Data JPA on the same Order aggregate: identical domain, identical H2
schema, real captured SQL logs and statement counts for both stacks.</description>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<!-- Spring Data JPA (Hibernate) stack, package com.ankurm.jdbcvsjpa.jpa -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Spring Data JDBC stack, package com.ankurm.jdbcvsjpa.jdbc -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,30 @@
package com.ankurm.jdbcvsjpa;
import com.ankurm.jdbcvsjpa.support.SqlLog;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
/**
* Dumps the real SQL statement log for whatever ran in this JVM since the last reset — the
* same mechanism the tests use to write docs/output transcripts, exposed so you can point it
* at your own scenario. Delete this before shipping; it is a diagnostic, not a feature. See
* docs/03-the-sql-log.md.
*/
@RestController
public class DiagController {
private final SqlLog sqlLog;
public DiagController(SqlLog sqlLog) {
this.sqlLog = sqlLog;
}
@GetMapping("/diag/sql-log")
public Map<String, Object> sqlLog() {
List<String> statements = sqlLog.statements();
return Map.of("count", statements.size(), "statements", statements);
}
}
@@ -0,0 +1,45 @@
package com.ankurm.jdbcvsjpa;
import com.ankurm.jdbcvsjpa.support.SqlLog;
import com.ankurm.jdbcvsjpa.support.StatementLoggingDataSource;
import com.zaxxer.hikari.HikariDataSource;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.data.jdbc.repository.config.EnableJdbcRepositories;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import javax.sql.DataSource;
/**
* Both stacks live in one Spring context on purpose — same DataSource, same
* {@link StatementLoggingDataSource} wrapper, so the SQL-statement counts in the post are a
* fair, apples-to-apples comparison rather than two separate benchmarks run under different
* conditions. {@code exclude = DataSourceAutoConfiguration.class} because we build the
* DataSource ourselves below (wrapped), and Boot backs off cleanly once one is excluded.
*/
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
@EnableJpaRepositories(basePackages = "com.ankurm.jdbcvsjpa.jpa")
@EnableJdbcRepositories(basePackages = "com.ankurm.jdbcvsjpa.jdbc")
public class JdbcVsJpaApplication {
public static void main(String[] args) {
SpringApplication.run(JdbcVsJpaApplication.class, args);
}
@Bean
public SqlLog sqlLog() {
return new SqlLog();
}
@Bean
public DataSource dataSource(SqlLog sqlLog) {
HikariDataSource real = new HikariDataSource();
real.setJdbcUrl("jdbc:h2:mem:jdbcvsjpa;DB_CLOSE_DELAY=-1;MODE=LEGACY");
real.setUsername("sa");
real.setPassword("");
real.setDriverClassName("org.h2.Driver");
return new StatementLoggingDataSource(real, sqlLog);
}
}
@@ -0,0 +1,17 @@
package com.ankurm.jdbcvsjpa.jdbc;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;
/**
* A Spring Data JDBC aggregate root in its own right — Customer is never loaded as part of an
* Order in this package. Orders refer to it by {@link AggregateReference}, not by embedding it.
* See docs/04-the-jdbc-side.md.
*/
@Table("JDBC_CUSTOMER")
public record Customer(@Id Long id, String name) {
public static Customer newCustomer(String name) {
return new Customer(null, name);
}
}
@@ -0,0 +1,6 @@
package com.ankurm.jdbcvsjpa.jdbc;
import org.springframework.data.repository.ListCrudRepository;
public interface JdbcCustomerRepository extends ListCrudRepository<Customer, Long> {
}
@@ -0,0 +1,15 @@
package com.ankurm.jdbcvsjpa.jdbc;
import org.springframework.data.jdbc.repository.query.Query;
import org.springframework.data.repository.ListCrudRepository;
import java.util.List;
public interface JdbcOrderRepository extends ListCrudRepository<Order, Long> {
// No @EntityGraph equivalent needed or available: findById ALWAYS loads the whole
// aggregate (order + its items) in a fixed number of statements. See docs/05.
@Query("select o.* from jdbc_order o where o.customer_id = :customerId")
List<Order> findByCustomer(Long customerId);
}
@@ -0,0 +1,71 @@
package com.ankurm.jdbcvsjpa.jdbc;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Version;
import org.springframework.data.jdbc.core.mapping.AggregateReference;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.MappedCollection;
import org.springframework.data.relational.core.mapping.Table;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* The Spring Data JDBC half of the shared domain. Order is the aggregate root: {@code items}
* is loaded and saved as a unit with it (see {@link #withItems}), and {@code customer} is held
* as an {@link AggregateReference} — a typed foreign key, not a loaded object — because
* Customer is a separate aggregate. {@code keyColumn = "order_key"} on the collection is what
* makes list order survive a reload; without it Spring Data JDBC still stores the list
* correctly but does not guarantee the order back. See docs/04-the-jdbc-side.md and
* docs/05-the-aggregate-boundary.md.
*/
@Table("JDBC_ORDER")
public class Order {
@Id
private Long id;
@Column("CUSTOMER_ID")
private AggregateReference<Customer, Long> customer;
@Version
private Long version;
@MappedCollection(idColumn = "ORDER_ID", keyColumn = "ORDER_KEY")
private List<OrderItem> items = new ArrayList<>();
protected Order() {
}
public Order(AggregateReference<Customer, Long> customer, List<OrderItem> items) {
this.customer = customer;
this.items = new ArrayList<>(items);
}
/** Returns a copy of this Order with a new item list — Spring Data JDBC entities are
* usually treated as immutable-ish value objects; mutating and re-saving the whole
* aggregate is the idiom, not patching individual rows. */
public Order withItems(List<OrderItem> newItems) {
Order copy = new Order(this.customer, newItems);
copy.id = this.id;
copy.version = this.version;
return copy;
}
public Long getId() {
return id;
}
public AggregateReference<Customer, Long> getCustomer() {
return customer;
}
public Long getVersion() {
return version;
}
public List<OrderItem> getItems() {
return Collections.unmodifiableList(items);
}
}
@@ -0,0 +1,18 @@
package com.ankurm.jdbcvsjpa.jdbc;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;
/**
* A child entity inside the Order aggregate. It has no reference back to its Order and no
* repository of its own — under Spring Data JDBC it cannot be saved, loaded, or deleted except
* as part of saving, loading, or deleting the Order that owns it. That is the aggregate
* boundary, not a modelling choice. See docs/04-the-jdbc-side.md.
*/
@Table("JDBC_ORDER_ITEM")
public record OrderItem(@Id Long id, String sku, int quantity) {
public static OrderItem line(String sku, int quantity) {
return new OrderItem(null, sku, quantity);
}
}
@@ -0,0 +1,37 @@
package com.ankurm.jdbcvsjpa.jpa;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
/**
* See docs/01-the-shared-domain.md. Deliberately the same shape as the Spring Data JDBC
* {@code jdbc.Customer} in the sibling package — same columns, same seed data, different table
* (jpa_customer vs jdbc_customer) so the two stacks never share rows.
*/
@Entity
@Table(name = "jpa_customer")
public class Customer {
@Id
@GeneratedValue(strategy = jakarta.persistence.GenerationType.IDENTITY)
private Long id;
private String name;
protected Customer() {
}
public Customer(String name) {
this.name = name;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
}
@@ -0,0 +1,6 @@
package com.ankurm.jdbcvsjpa.jpa;
import org.springframework.data.jpa.repository.JpaRepository;
public interface JpaCustomerRepository extends JpaRepository<Customer, Long> {
}
@@ -0,0 +1,18 @@
package com.ankurm.jdbcvsjpa.jpa;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface JpaOrderRepository extends JpaRepository<Order, Long> {
// Plain findById: LAZY collection and LAZY customer are NOT fetched here.
// Left as the inherited default on purpose — see the N+1 section.
@EntityGraph(attributePaths = {"items", "customer"})
Optional<Order> findWithItemsAndCustomerById(Long id);
List<Order> findByCustomerId(Long customerId);
}
@@ -0,0 +1,75 @@
package com.ankurm.jdbcvsjpa.jpa;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* The JPA half of the shared domain. Everything here needs explicit cascade/orphanRemoval and
* an explicit fetch type, because Hibernate's defaults for {@code @OneToMany} are
* {@code LAZY} but for {@code @ManyToOne} are {@code EAGER} unless overridden — a detail most
* tutorials skip and this class makes both explicit rather than relying on the default.
* See docs/02-the-jpa-side.md.
*/
@Entity
@Table(name = "jpa_order")
public class Order {
@Id
@GeneratedValue(strategy = jakarta.persistence.GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
@Version
private Long version;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private List<OrderItem> items = new ArrayList<>();
protected Order() {
}
public Order(Customer customer) {
this.customer = customer;
}
public void addItem(OrderItem item) {
items.add(item);
item.setOrder(this);
}
public void removeItem(OrderItem item) {
items.remove(item);
item.setOrder(null);
}
public Long getId() {
return id;
}
public Customer getCustomer() {
return customer;
}
public Long getVersion() {
return version;
}
public List<OrderItem> getItems() {
return Collections.unmodifiableList(items);
}
}
@@ -0,0 +1,56 @@
package com.ankurm.jdbcvsjpa.jpa;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "jpa_order_item")
public class OrderItem {
@Id
@GeneratedValue(strategy = jakarta.persistence.GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = jakarta.persistence.FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;
private String sku;
private int quantity;
protected OrderItem() {
}
public OrderItem(String sku, int quantity) {
this.sku = sku;
this.quantity = quantity;
}
public Long getId() {
return id;
}
public Order getOrder() {
return order;
}
void setOrder(Order order) {
this.order = order;
}
public String getSku() {
return sku;
}
public int getQuantity() {
return quantity;
}
public void setQuantity(int quantity) {
this.quantity = quantity;
}
}
@@ -0,0 +1,59 @@
package com.ankurm.jdbcvsjpa.support;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* Every SQL statement executed against the shared H2 database, in order, regardless of
* whether it came from Hibernate (JPA) or Spring Data JDBC's {@code JdbcTemplate}. Populated
* by {@link StatementLoggingDataSource}. Reset between test cases so counts are per-scenario,
* not cumulative across the whole test run.
*
* See docs/03-the-sql-log.md for why this exists instead of trusting each framework's own
* "show-sql" logging (they don't format the same, and Spring Data JDBC's default logging
* doesn't show bind values at INFO).
*/
@Component
public class SqlLog {
private final List<String> statements = Collections.synchronizedList(new ArrayList<>());
public void record(String sql) {
if (sql != null) {
statements.add(sql.strip());
}
}
public List<String> statements() {
synchronized (statements) {
return List.copyOf(statements);
}
}
public int count() {
return statements.size();
}
public long countContaining(String needle) {
synchronized (statements) {
return statements.stream().filter(s -> s.toLowerCase().contains(needle.toLowerCase())).count();
}
}
public void reset() {
statements.clear();
}
public String render() {
StringBuilder sb = new StringBuilder();
List<String> snapshot = statements();
for (int i = 0; i < snapshot.size(); i++) {
sb.append(String.format("%2d. %s%n", i + 1, snapshot.get(i)));
}
sb.append(String.format("%ntotal statements: %d%n", snapshot.size()));
return sb.toString();
}
}
@@ -0,0 +1,112 @@
package com.ankurm.jdbcvsjpa.support;
import javax.sql.DataSource;
import java.io.PrintWriter;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.sql.SQLFeatureNotSupportedException;
import java.sql.Statement;
import java.util.logging.Logger;
/**
* A {@link DataSource} decorator that records every statement executed through it into a
* {@link SqlLog}, by wrapping each {@link Connection} and each {@link Statement}/
* {@link PreparedStatement} it hands out in a JDK dynamic proxy.
*
* <p>This is deliberately framework-agnostic: it sits below both Hibernate and Spring Data
* JDBC's {@code JdbcTemplate}, so it counts what actually reached the database, not what each
* ORM's own debug logging chooses to print. See docs/03-the-sql-log.md.
*/
public final class StatementLoggingDataSource implements DataSource {
private final DataSource delegate;
private final SqlLog sqlLog;
public StatementLoggingDataSource(DataSource delegate, SqlLog sqlLog) {
this.delegate = delegate;
this.sqlLog = sqlLog;
}
@Override
public Connection getConnection() throws SQLException {
return wrapConnection(delegate.getConnection());
}
@Override
public Connection getConnection(String username, String password) throws SQLException {
return wrapConnection(delegate.getConnection(username, password));
}
private Connection wrapConnection(Connection real) {
InvocationHandler handler = (proxy, method, args) -> {
Object result = method.invoke(real, args);
String name = method.getName();
if (result instanceof Statement && ("prepareStatement".equals(name) || "createStatement".equals(name))) {
String knownSql = (args != null && args.length > 0 && args[0] instanceof String s) ? s : null;
return wrapStatement(result, knownSql);
}
return result;
};
return (Connection) Proxy.newProxyInstance(
Connection.class.getClassLoader(), new Class<?>[]{Connection.class}, handler);
}
private Object wrapStatement(Object real, String knownSql) {
Class<?> iface = real instanceof PreparedStatement ? PreparedStatement.class : Statement.class;
InvocationHandler handler = (proxy, method, args) -> {
String name = method.getName();
if (name.startsWith("execute")) {
String sql = knownSql != null
? knownSql
: (args != null && args.length > 0 && args[0] instanceof String s ? s : "<batched/unknown>");
sqlLog.record(sql);
}
return method.invoke(real, args);
};
return Proxy.newProxyInstance(iface.getClassLoader(), new Class<?>[]{iface}, handler);
}
// --- plain delegation for the rest of javax.sql.DataSource ---
@Override
public PrintWriter getLogWriter() throws SQLException {
return delegate.getLogWriter();
}
@Override
public void setLogWriter(PrintWriter out) throws SQLException {
delegate.setLogWriter(out);
}
@Override
public void setLoginTimeout(int seconds) throws SQLException {
delegate.setLoginTimeout(seconds);
}
@Override
public int getLoginTimeout() throws SQLException {
return delegate.getLoginTimeout();
}
@Override
public Logger getParentLogger() throws SQLFeatureNotSupportedException {
return Logger.getLogger("com.ankurm.jdbcvsjpa.support.StatementLoggingDataSource");
}
@Override
public <T> T unwrap(Class<T> iface) throws SQLException {
if (iface.isInstance(this)) {
return iface.cast(this);
}
return delegate.unwrap(iface);
}
@Override
public boolean isWrapperFor(Class<?> iface) throws SQLException {
return iface.isInstance(this) || delegate.isWrapperFor(iface);
}
}
@@ -0,0 +1,45 @@
package com.ankurm.jdbcvsjpa.support;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
/**
* Writes a named file under {@code docs/output/} and echoes the same text to stdout. Every
* console block quoted in the article comes from one of these files — see house-style's
* mandatory repository-link rule. Tests that call this are, by construction, the source of
* every number in the post: if the behaviour changes, the build breaks before the post can go
* stale.
*/
public final class Transcript {
private static final Path OUTPUT_DIR = resolveOutputDir();
private Transcript() {
}
private static Path resolveOutputDir() {
// Tests run from the module directory (surefire's working dir); fall back to cwd/docs/output.
Path here = Paths.get("docs", "output");
try {
Files.createDirectories(here);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
return here;
}
public static void write(String filename, String content) {
Path target = OUTPUT_DIR.resolve(filename);
try {
Files.writeString(target, content, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
System.out.println("=== " + filename + " ===");
System.out.println(content);
}
}
@@ -0,0 +1,18 @@
spring:
application:
name: jdbc-vs-jpa
sql:
init:
mode: always
jpa:
hibernate:
ddl-auto: none
open-in-view: false
properties:
hibernate:
show_sql: false # we read real statements from StatementLoggingDataSource instead
logging:
level:
root: WARN
com.ankurm.jdbcvsjpa: INFO
+48
View File
@@ -0,0 +1,48 @@
-- Two parallel schemas on the same H2 database: jpa_* tables for the Hibernate/JPA stack,
-- jdbc_* tables for the Spring Data JDBC stack. Same shape, same seed data (see
-- DemoDataInitializer in the test sources), never the same rows.
drop table if exists jpa_order_item;
drop table if exists jpa_order;
drop table if exists jpa_customer;
create table jpa_customer (
id bigint auto_increment primary key,
name varchar(100) not null
);
create table jpa_order (
id bigint auto_increment primary key,
customer_id bigint not null references jpa_customer(id),
version bigint
);
create table jpa_order_item (
id bigint auto_increment primary key,
order_id bigint not null references jpa_order(id),
sku varchar(50) not null,
quantity int not null
);
drop table if exists jdbc_order_item;
drop table if exists jdbc_order;
drop table if exists jdbc_customer;
create table jdbc_customer (
id bigint auto_increment primary key,
name varchar(100) not null
);
create table jdbc_order (
id bigint auto_increment primary key,
customer_id bigint not null references jdbc_customer(id),
version bigint
);
create table jdbc_order_item (
id bigint auto_increment primary key,
order_id bigint not null references jdbc_order(id),
order_key int,
sku varchar(50) not null,
quantity int not null
);
@@ -0,0 +1,114 @@
package com.ankurm.jdbcvsjpa;
import com.ankurm.jdbcvsjpa.jdbc.Customer;
import com.ankurm.jdbcvsjpa.jdbc.JdbcCustomerRepository;
import com.ankurm.jdbcvsjpa.jdbc.Order;
import com.ankurm.jdbcvsjpa.jdbc.OrderItem;
import com.ankurm.jdbcvsjpa.jdbc.JdbcOrderRepository;
import com.ankurm.jdbcvsjpa.support.SqlLog;
import com.ankurm.jdbcvsjpa.support.Transcript;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.jdbc.core.mapping.AggregateReference;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The single table the article leads with: statement counts for the SAME logical operation
* (load N orders, each with M items, then read every item) under naive JPA (no fetch join)
* and under Spring Data JDBC, for a few values of N and M. Both paths run against the same
* StatementLoggingDataSource, so the numbers are directly comparable.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
class ComparisonTest {
@Autowired
com.ankurm.jdbcvsjpa.jpa.JpaOrderRepository jpaOrderRepository;
@Autowired
com.ankurm.jdbcvsjpa.jpa.JpaCustomerRepository jpaCustomerRepository;
@Autowired
JdbcOrderRepository jdbcOrderRepository;
@Autowired
JdbcCustomerRepository jdbcCustomerRepository;
@Autowired
SqlLog sqlLog;
@Autowired
org.springframework.transaction.PlatformTransactionManager txManager;
@Test
void statementCountsAcrossOrderAndItemCounts() {
StringBuilder table = new StringBuilder();
table.append(String.format("%-10s %-10s %-22s %-22s%n", "orders", "items/ea", "JPA (no fetch join)", "Spring Data JDBC"));
int[][] scenarios = {{1, 1}, {1, 5}, {5, 1}, {5, 5}, {20, 3}};
for (int[] scenario : scenarios) {
int orderCount = scenario[0];
int itemsEach = scenario[1];
long jpaStatements = runJpaScenario(orderCount, itemsEach);
long jdbcStatements = runJdbcScenario(orderCount, itemsEach);
table.append(String.format("%-10d %-10d %-22d %-22d%n", orderCount, itemsEach, jpaStatements, jdbcStatements));
}
String transcript = """
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.
%s
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.
""".formatted(table);
Transcript.write("00-statement-count-comparison.txt", transcript);
assertThat(table.toString()).contains("orders");
}
private long runJpaScenario(int orderCount, int itemsEach) {
org.springframework.transaction.support.TransactionTemplate tx =
new org.springframework.transaction.support.TransactionTemplate(txManager);
Long customerId = tx.execute(status -> {
com.ankurm.jdbcvsjpa.jpa.Customer customer =
jpaCustomerRepository.save(new com.ankurm.jdbcvsjpa.jpa.Customer("Comparison Customer JPA " + orderCount + "x" + itemsEach));
for (int o = 0; o < orderCount; o++) {
com.ankurm.jdbcvsjpa.jpa.Order order = new com.ankurm.jdbcvsjpa.jpa.Order(customer);
for (int i = 0; i < itemsEach; i++) {
order.addItem(new com.ankurm.jdbcvsjpa.jpa.OrderItem("SKU-" + i, i + 1));
}
jpaOrderRepository.save(order);
}
return customer.getId();
});
sqlLog.reset();
tx.execute(status -> {
List<com.ankurm.jdbcvsjpa.jpa.Order> orders = jpaOrderRepository.findByCustomerId(customerId);
long items = orders.stream().mapToLong(o -> o.getItems().size()).sum();
return items;
});
return sqlLog.count();
}
private long runJdbcScenario(int orderCount, int itemsEach) {
Customer customer = jdbcCustomerRepository.save(
Customer.newCustomer("Comparison Customer JDBC " + orderCount + "x" + itemsEach));
for (int o = 0; o < orderCount; o++) {
List<OrderItem> items = new ArrayList<>();
for (int i = 0; i < itemsEach; i++) {
items.add(OrderItem.line("SKU-" + i, i + 1));
}
jdbcOrderRepository.save(new Order(AggregateReference.to(customer.id()), items));
}
sqlLog.reset();
List<Order> orders = jdbcOrderRepository.findByCustomer(customer.id());
long items = orders.stream().mapToLong(o -> o.getItems().size()).sum();
return sqlLog.count();
}
}
@@ -0,0 +1,217 @@
package com.ankurm.jdbcvsjpa;
import com.ankurm.jdbcvsjpa.jdbc.Customer;
import com.ankurm.jdbcvsjpa.jdbc.JdbcCustomerRepository;
import com.ankurm.jdbcvsjpa.jdbc.Order;
import com.ankurm.jdbcvsjpa.jdbc.OrderItem;
import com.ankurm.jdbcvsjpa.jdbc.JdbcOrderRepository;
import com.ankurm.jdbcvsjpa.support.SqlLog;
import com.ankurm.jdbcvsjpa.support.Transcript;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.data.jdbc.core.mapping.AggregateReference;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The Spring Data JDBC counterpart to JpaBehaviorTest, on the identical scenario shapes so the
* two can be compared line for line. docs/04-the-jdbc-side.md and docs/05-the-aggregate-boundary.md
* are the narrative versions.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
class JdbcBehaviorTest {
@Autowired
JdbcOrderRepository orderRepository;
@Autowired
JdbcCustomerRepository customerRepository;
@Autowired
SqlLog sqlLog;
@BeforeEach
void setUp() {
orderRepository.deleteAll();
customerRepository.deleteAll();
sqlLog.reset();
}
private Order seedOrder(int itemCount) {
Customer customer = customerRepository.save(Customer.newCustomer("Priya Nair"));
List<OrderItem> items = new java.util.ArrayList<>();
for (int i = 1; i <= itemCount; i++) {
items.add(OrderItem.line("SKU-" + i, i));
}
Order order = new Order(AggregateReference.to(customer.id()), items);
return orderRepository.save(order);
}
@Test
void a_findByIdLoadsTheWholeAggregateInAFixedStatementCount() {
Order one = seedOrder(1);
Order five = seedOrder(5);
sqlLog.reset();
Order reloadedOne = orderRepository.findById(one.getId()).orElseThrow();
long statementsForOne = sqlLog.count();
int itemsForOne = reloadedOne.getItems().size();
sqlLog.reset();
Order reloadedFive = orderRepository.findById(five.getId()).orElseThrow();
long statementsForFive = sqlLog.count();
int itemsForFive = reloadedFive.getItems().size();
String transcript = """
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: %d (items loaded: %d)
statements for 5-item order: %d (items loaded: %d)
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.
""".formatted(statementsForOne, itemsForOne, statementsForFive, itemsForFive);
Transcript.write("06-fixed-cost-load.txt", transcript);
assertThat(statementsForOne).isEqualTo(statementsForFive);
assertThat(itemsForOne).isEqualTo(1);
assertThat(itemsForFive).isEqualTo(5);
}
@Test
void b_savingChangesOneItemDeletesAndReinsertsAllItems() {
Order order = seedOrder(3);
sqlLog.reset();
List<OrderItem> mutated = order.getItems().stream()
.map(item -> item.sku().equals("SKU-2") ? new OrderItem(item.id(), item.sku(), 999) : item)
.toList();
orderRepository.save(order.withItems(mutated));
long deletes = sqlLog.countContaining("delete");
long insertStatements = sqlLog.countContaining("insert");
List<OrderItem> reloaded = orderRepository.findById(order.getId()).orElseThrow().getItems();
String transcript = """
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).
%s
DELETE statements: %d
INSERT statements: %d (but the reload shows %d rows back: %s)
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.
""".formatted(sqlLog.render(), deletes, insertStatements, reloaded.size(), reloaded);
Transcript.write("07-delete-then-insert.txt", transcript);
assertThat(deletes).isGreaterThanOrEqualTo(1);
assertThat(insertStatements).isEqualTo(1); // one batch execution, not one per row
assertThat(reloaded).hasSize(3); // but all three rows really were rewritten
assertThat(reloaded).anyMatch(i -> i.sku().equals("SKU-2") && i.quantity() == 999);
}
@Test
void c_aggregateReferenceDoesNotLoadTheCustomer() {
Order order = seedOrder(2);
sqlLog.reset();
Order reloaded = orderRepository.findById(order.getId()).orElseThrow();
Long customerId = reloaded.getCustomer().getId();
long customerSelects = sqlLog.countContaining("jdbc_customer");
String transcript = """
Scenario: load an order, then read its customer's id through the
AggregateReference — never call customerRepository.findById for it.
%s
statements touching jdbc_customer: %d
customer id obtained without a lookup: %d
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.
""".formatted(sqlLog.render(), customerSelects, customerId);
Transcript.write("08-aggregate-reference-no-join.txt", transcript);
assertThat(customerSelects).isZero();
assertThat(customerId).isEqualTo(order.getCustomer().getId());
}
@Test
void d_optimisticLockingRejectsAStaleVersion() {
Order order = seedOrder(1);
Order firstLoad = orderRepository.findById(order.getId()).orElseThrow();
Order secondLoad = orderRepository.findById(order.getId()).orElseThrow();
orderRepository.save(firstLoad.withItems(firstLoad.getItems())); // version bumps
Throwable thrown = org.assertj.core.api.Assertions.catchThrowable(
() -> orderRepository.save(secondLoad.withItems(secondLoad.getItems())));
String transcript = """
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: %s
@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.
""".formatted(thrown);
Transcript.write("09-optimistic-locking.txt", transcript);
assertThat(thrown).isInstanceOf(OptimisticLockingFailureException.class);
}
@Test
void e_mappedCollectionOrderSurvivesAReloadBecauseOfKeyColumn() {
List<OrderItem> items = List.of(
OrderItem.line("SKU-Z", 1),
OrderItem.line("SKU-A", 2),
OrderItem.line("SKU-M", 3));
Customer customer = customerRepository.save(Customer.newCustomer("Order Test"));
Order saved = orderRepository.save(new Order(AggregateReference.to(customer.id()), items));
sqlLog.reset();
Order reloaded = orderRepository.findById(saved.getId()).orElseThrow();
List<String> order = reloaded.getItems().stream().map(OrderItem::sku).toList();
String transcript = """
Scenario: save items in the order [SKU-Z, SKU-A, SKU-M] (deliberately not
alphabetical), reload, and read the list back.
reloaded order: %s
@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.
""".formatted(order);
Transcript.write("10-list-order-key-column.txt", transcript);
assertThat(order).containsExactly("SKU-Z", "SKU-A", "SKU-M");
}
}
@@ -0,0 +1,219 @@
package com.ankurm.jdbcvsjpa;
import com.ankurm.jdbcvsjpa.jpa.Customer;
import com.ankurm.jdbcvsjpa.jpa.JpaCustomerRepository;
import com.ankurm.jdbcvsjpa.jpa.Order;
import com.ankurm.jdbcvsjpa.jpa.OrderItem;
import com.ankurm.jdbcvsjpa.jpa.JpaOrderRepository;
import com.ankurm.jdbcvsjpa.support.SqlLog;
import com.ankurm.jdbcvsjpa.support.Transcript;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Everything a reader is told about the JPA side of this article is asserted here first.
* docs/02-the-jpa-side.md is the narrative version of these tests.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
class JpaBehaviorTest {
@Autowired
JpaOrderRepository orderRepository;
@Autowired
JpaCustomerRepository customerRepository;
@Autowired
SqlLog sqlLog;
@Autowired
PlatformTransactionManager txManager;
TransactionTemplate tx;
@BeforeEach
void setUp() {
tx = new TransactionTemplate(txManager);
// Tests share one Spring context (and one H2 database) for speed, so each test must
// clear the tables itself rather than relying on a fresh schema.
tx.execute(status -> {
orderRepository.deleteAll();
customerRepository.deleteAll();
return null;
});
sqlLog.reset();
}
private Long seedOrder(int itemCount) {
return tx.execute(status -> {
Customer customer = customerRepository.save(new Customer("Priya Nair"));
Order order = new Order(customer);
for (int i = 1; i <= itemCount; i++) {
order.addItem(new OrderItem("SKU-" + i, i));
}
return orderRepository.save(order).getId();
});
}
@Test
void a_lazyCollectionAccessedOutsideTheSessionThrows() {
Long orderId = seedOrder(3);
sqlLog.reset();
// Loaded and returned from its own transaction — the Hibernate session closes when
// that transaction commits, because open-in-view is off (see application.yml).
Order detached = tx.execute(status -> orderRepository.findById(orderId).orElseThrow());
Throwable thrown = catchThrowable(() -> detached.getItems().size());
String transcript = """
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() -> %s
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.
""".formatted(thrown == null ? "no exception (see repo notes)" : thrown.toString());
Transcript.write("01-lazy-outside-session.txt", transcript);
assertThat(thrown)
.isNotNull()
.hasMessageContaining("no session")
.isInstanceOf(org.hibernate.LazyInitializationException.class);
}
private static Throwable catchThrowable(Runnable r) {
try {
r.run();
return null;
} catch (Throwable t) {
return t;
}
}
@Test
void b_findAllThenTouchItemsIsNPlusOne() {
seedOrder(2);
seedOrder(2);
seedOrder(2);
sqlLog.reset();
tx.execute(status -> {
List<Order> orders = orderRepository.findAll(); // statement 1
long total = orders.stream()
.mapToLong(o -> o.getItems().size()) // one SELECT per order, lazily
.sum();
return total;
});
long selects = sqlLog.countContaining("select");
String transcript = """
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.
%s
SELECT statements observed: %d (1 for the orders themselves + 1 per order for
its lazily-loaded items = N+1, here 1 + 3 = 4)
""".formatted(sqlLog.render(), selects);
Transcript.write("02-n-plus-one.txt", transcript);
assertThat(selects).isEqualTo(4); // 1 (orders) + 3 (one per order's items)
}
@Test
void c_entityGraphFetchesInAFixedNumberOfStatements() {
Long id1 = seedOrder(1);
Long id5 = seedOrder(5);
sqlLog.reset();
tx.execute(status -> orderRepository.findWithItemsAndCustomerById(id1).orElseThrow().getItems().size());
long statementsForOne = sqlLog.count();
sqlLog.reset();
tx.execute(status -> orderRepository.findWithItemsAndCustomerById(id5).orElseThrow().getItems().size());
long statementsForFive = sqlLog.count();
String transcript = """
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: %d
statements for 5-item order: %d
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).
""".formatted(statementsForOne, statementsForFive);
Transcript.write("03-entity-graph-fixed-cost.txt", transcript);
assertThat(statementsForOne).isEqualTo(statementsForFive);
}
@Test
void d_dirtyCheckingFlushesWithoutAnExplicitSave() {
Long orderId = seedOrder(1);
sqlLog.reset();
tx.execute(status -> {
Order order = orderRepository.findWithItemsAndCustomerById(orderId).orElseThrow();
order.getItems().get(0).setQuantity(99); // no save() call anywhere
long selectsBeforeSecondQuery = sqlLog.countContaining("update");
// Triggering a second query in the same session forces a flush of the pending change first.
customerRepository.count();
return selectsBeforeSecondQuery;
});
String transcript = """
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.
%s
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).
""".formatted(sqlLog.render());
Transcript.write("04-dirty-checking-autoflush.txt", transcript);
assertThat(sqlLog.countContaining("update")).isGreaterThanOrEqualTo(1);
}
@Test
void e_orphanRemovalDeletesOnSave() {
Long orderId = seedOrder(2);
sqlLog.reset();
tx.execute(status -> {
Order order = orderRepository.findWithItemsAndCustomerById(orderId).orElseThrow();
OrderItem toRemove = order.getItems().get(0);
order.removeItem(toRemove);
return orderRepository.save(order);
});
String transcript = """
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.
%s
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.
""".formatted(sqlLog.render());
Transcript.write("05-orphan-removal-delete.txt", transcript);
assertThat(sqlLog.countContaining("delete")).isEqualTo(1);
}
}
+6 -3
View File
@@ -15,9 +15,11 @@
An aggregator only. Each module declares its own spring-boot-starter-parent, deliberately: An aggregator only. Each module declares its own spring-boot-starter-parent, deliberately:
migration-behavior Spring Boot 4.0.6 / JDK 21: the versions the four published migration-behavior Spring Boot 4.0.6 / JDK 21: the versions the four published
migration articles were written and verified against. Upgrading it migration articles were written and verified against. Upgrading it
would silently invalidate output those articles quote. would silently invalidate output those articles quote.
transactions Spring Boot 4.1.1 / JDK 25, current at the time of writing. transactions Spring Boot 4.1.1 / JDK 25, current at the time of writing.
jdbc-vs-jpa Spring Boot 4.1.1 / JDK 25. Same domain modelled with Spring Data JPA
and Spring Data JDBC side by side, for the "when to drop the ORM" article.
Keeping them apart costs one extra pom and means neither article's evidence rots when the Keeping them apart costs one extra pom and means neither article's evidence rots when the
other module moves on. other module moves on.
@@ -25,5 +27,6 @@
<modules> <modules>
<module>migration-behavior</module> <module>migration-behavior</module>
<module>transactions</module> <module>transactions</module>
<module>jdbc-vs-jpa</module>
</modules> </modules>
</project> </project>