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:
@@ -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 3→4 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).
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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).
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+112
@@ -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
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@
|
|||||||
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>
|
||||||
|
|||||||
Reference in New Issue
Block a user