diff --git a/README.md b/README.md index 7028aef..28cdbc7 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,17 @@ 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 | |---|---|---| -| [`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 | +| [`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 -4.0.6 because that is what the four published migration articles were written and verified -against, and upgrading it would silently invalidate output those articles quote. +The modules deliberately pin different Spring Boot versions. `migration-behavior` stays on 4.0.6 +because that is what the four published migration articles were written and verified against, +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 > 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. ```bash -./mvnw -DskipTests package # both modules -./mvnw test # every test in both +./mvnw -DskipTests package # all modules +./mvnw test # every test in all three ``` ## Tags -- `article-1-baseline`, `article-2-query-engine`, `article-3-advanced` - the exact code each 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. +- `article-1-baseline`, `article-2-query-engine`, `article-3-advanced` - the exact code each + 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) -- **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). -- **`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 work for nested/embedded paths. See `BookRepository.findByPriceAmountLessThanEqual`. +- **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). +- **`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). diff --git a/jdbc-vs-jpa/README.md b/jdbc-vs-jpa/README.md new file mode 100644 index 0000000..800c0fb --- /dev/null +++ b/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. diff --git a/jdbc-vs-jpa/docs/01-the-shared-domain.md b/jdbc-vs-jpa/docs/01-the-shared-domain.md new file mode 100644 index 0000000..bcf65f9 --- /dev/null +++ b/jdbc-vs-jpa/docs/01-the-shared-domain.md @@ -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` — a typed foreign key, never a loaded object | +| Order → items | `@OneToMany(mappedBy, cascade = ALL, orphanRemoval = true)`, `List` | `@MappedCollection(idColumn, keyColumn)`, `List` | +| 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). diff --git a/jdbc-vs-jpa/docs/02-the-jpa-side.md b/jdbc-vs-jpa/docs/02-the-jpa-side.md new file mode 100644 index 0000000..b813916 --- /dev/null +++ b/jdbc-vs-jpa/docs/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). diff --git a/jdbc-vs-jpa/docs/03-the-sql-log.md b/jdbc-vs-jpa/docs/03-the-sql-log.md new file mode 100644 index 0000000..150a199 --- /dev/null +++ b/jdbc-vs-jpa/docs/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). diff --git a/jdbc-vs-jpa/docs/04-the-jdbc-side.md b/jdbc-vs-jpa/docs/04-the-jdbc-side.md new file mode 100644 index 0000000..a276ade --- /dev/null +++ b/jdbc-vs-jpa/docs/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). diff --git a/jdbc-vs-jpa/docs/05-the-aggregate-boundary.md b/jdbc-vs-jpa/docs/05-the-aggregate-boundary.md new file mode 100644 index 0000000..fa3a0b1 --- /dev/null +++ b/jdbc-vs-jpa/docs/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` 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`, 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). diff --git a/jdbc-vs-jpa/docs/06-when-this-gets-expensive.md b/jdbc-vs-jpa/docs/06-when-this-gets-expensive.md new file mode 100644 index 0000000..22ad190 --- /dev/null +++ b/jdbc-vs-jpa/docs/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) diff --git a/jdbc-vs-jpa/docs/output/00-statement-count-comparison.txt b/jdbc-vs-jpa/docs/output/00-statement-count-comparison.txt new file mode 100644 index 0000000..8fd9c4a --- /dev/null +++ b/jdbc-vs-jpa/docs/output/00-statement-count-comparison.txt @@ -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. diff --git a/jdbc-vs-jpa/docs/output/01-lazy-outside-session.txt b/jdbc-vs-jpa/docs/output/01-lazy-outside-session.txt new file mode 100644 index 0000000..2f62721 --- /dev/null +++ b/jdbc-vs-jpa/docs/output/01-lazy-outside-session.txt @@ -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. diff --git a/jdbc-vs-jpa/docs/output/02-n-plus-one.txt b/jdbc-vs-jpa/docs/output/02-n-plus-one.txt new file mode 100644 index 0000000..3038cec --- /dev/null +++ b/jdbc-vs-jpa/docs/output/02-n-plus-one.txt @@ -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) diff --git a/jdbc-vs-jpa/docs/output/03-entity-graph-fixed-cost.txt b/jdbc-vs-jpa/docs/output/03-entity-graph-fixed-cost.txt new file mode 100644 index 0000000..50c350b --- /dev/null +++ b/jdbc-vs-jpa/docs/output/03-entity-graph-fixed-cost.txt @@ -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). diff --git a/jdbc-vs-jpa/docs/output/04-dirty-checking-autoflush.txt b/jdbc-vs-jpa/docs/output/04-dirty-checking-autoflush.txt new file mode 100644 index 0000000..8e8c0dd --- /dev/null +++ b/jdbc-vs-jpa/docs/output/04-dirty-checking-autoflush.txt @@ -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). diff --git a/jdbc-vs-jpa/docs/output/05-orphan-removal-delete.txt b/jdbc-vs-jpa/docs/output/05-orphan-removal-delete.txt new file mode 100644 index 0000000..0f335ef --- /dev/null +++ b/jdbc-vs-jpa/docs/output/05-orphan-removal-delete.txt @@ -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. diff --git a/jdbc-vs-jpa/docs/output/06-fixed-cost-load.txt b/jdbc-vs-jpa/docs/output/06-fixed-cost-load.txt new file mode 100644 index 0000000..2bb6dad --- /dev/null +++ b/jdbc-vs-jpa/docs/output/06-fixed-cost-load.txt @@ -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. diff --git a/jdbc-vs-jpa/docs/output/07-delete-then-insert.txt b/jdbc-vs-jpa/docs/output/07-delete-then-insert.txt new file mode 100644 index 0000000..e0e5ca2 --- /dev/null +++ b/jdbc-vs-jpa/docs/output/07-delete-then-insert.txt @@ -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. diff --git a/jdbc-vs-jpa/docs/output/08-aggregate-reference-no-join.txt b/jdbc-vs-jpa/docs/output/08-aggregate-reference-no-join.txt new file mode 100644 index 0000000..e8fa7bd --- /dev/null +++ b/jdbc-vs-jpa/docs/output/08-aggregate-reference-no-join.txt @@ -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 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. diff --git a/jdbc-vs-jpa/docs/output/09-optimistic-locking.txt b/jdbc-vs-jpa/docs/output/09-optimistic-locking.txt new file mode 100644 index 0000000..8106ccd --- /dev/null +++ b/jdbc-vs-jpa/docs/output/09-optimistic-locking.txt @@ -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. diff --git a/jdbc-vs-jpa/docs/output/10-list-order-key-column.txt b/jdbc-vs-jpa/docs/output/10-list-order-key-column.txt new file mode 100644 index 0000000..44c6a2d --- /dev/null +++ b/jdbc-vs-jpa/docs/output/10-list-order-key-column.txt @@ -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. diff --git a/jdbc-vs-jpa/pom.xml b/jdbc-vs-jpa/pom.xml new file mode 100644 index 0000000..cc6fdb8 --- /dev/null +++ b/jdbc-vs-jpa/pom.xml @@ -0,0 +1,64 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + jdbc-vs-jpa + 1.0.0 + jdbc-vs-jpa + 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. + + + 25 + + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.springframework.boot + spring-boot-starter-data-jdbc + + + + org.springframework.boot + spring-boot-starter-webmvc + + + + com.h2database + h2 + runtime + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/DiagController.java b/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/DiagController.java new file mode 100644 index 0000000..45bd33b --- /dev/null +++ b/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/DiagController.java @@ -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 sqlLog() { + List statements = sqlLog.statements(); + return Map.of("count", statements.size(), "statements", statements); + } +} diff --git a/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/JdbcVsJpaApplication.java b/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/JdbcVsJpaApplication.java new file mode 100644 index 0000000..7c88f44 --- /dev/null +++ b/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/JdbcVsJpaApplication.java @@ -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); + } +} diff --git a/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/jdbc/Customer.java b/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/jdbc/Customer.java new file mode 100644 index 0000000..692364b --- /dev/null +++ b/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/jdbc/Customer.java @@ -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); + } +} diff --git a/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/jdbc/JdbcCustomerRepository.java b/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/jdbc/JdbcCustomerRepository.java new file mode 100644 index 0000000..686d55b --- /dev/null +++ b/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/jdbc/JdbcCustomerRepository.java @@ -0,0 +1,6 @@ +package com.ankurm.jdbcvsjpa.jdbc; + +import org.springframework.data.repository.ListCrudRepository; + +public interface JdbcCustomerRepository extends ListCrudRepository { +} diff --git a/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/jdbc/JdbcOrderRepository.java b/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/jdbc/JdbcOrderRepository.java new file mode 100644 index 0000000..0afb067 --- /dev/null +++ b/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/jdbc/JdbcOrderRepository.java @@ -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 { + + // 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 findByCustomer(Long customerId); +} diff --git a/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/jdbc/Order.java b/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/jdbc/Order.java new file mode 100644 index 0000000..d88ff67 --- /dev/null +++ b/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/jdbc/Order.java @@ -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; + + @Version + private Long version; + + @MappedCollection(idColumn = "ORDER_ID", keyColumn = "ORDER_KEY") + private List items = new ArrayList<>(); + + protected Order() { + } + + public Order(AggregateReference customer, List 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 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 getCustomer() { + return customer; + } + + public Long getVersion() { + return version; + } + + public List getItems() { + return Collections.unmodifiableList(items); + } +} diff --git a/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/jdbc/OrderItem.java b/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/jdbc/OrderItem.java new file mode 100644 index 0000000..871a07f --- /dev/null +++ b/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/jdbc/OrderItem.java @@ -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); + } +} diff --git a/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/jpa/Customer.java b/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/jpa/Customer.java new file mode 100644 index 0000000..9e831cc --- /dev/null +++ b/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/jpa/Customer.java @@ -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; + } +} diff --git a/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/jpa/JpaCustomerRepository.java b/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/jpa/JpaCustomerRepository.java new file mode 100644 index 0000000..0e61235 --- /dev/null +++ b/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/jpa/JpaCustomerRepository.java @@ -0,0 +1,6 @@ +package com.ankurm.jdbcvsjpa.jpa; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface JpaCustomerRepository extends JpaRepository { +} diff --git a/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/jpa/JpaOrderRepository.java b/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/jpa/JpaOrderRepository.java new file mode 100644 index 0000000..7352211 --- /dev/null +++ b/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/jpa/JpaOrderRepository.java @@ -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 { + + // 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 findWithItemsAndCustomerById(Long id); + + List findByCustomerId(Long customerId); +} diff --git a/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/jpa/Order.java b/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/jpa/Order.java new file mode 100644 index 0000000..ab351bf --- /dev/null +++ b/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/jpa/Order.java @@ -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 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 getItems() { + return Collections.unmodifiableList(items); + } +} diff --git a/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/jpa/OrderItem.java b/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/jpa/OrderItem.java new file mode 100644 index 0000000..6ba9e97 --- /dev/null +++ b/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/jpa/OrderItem.java @@ -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; + } +} diff --git a/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/support/SqlLog.java b/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/support/SqlLog.java new file mode 100644 index 0000000..cf952b7 --- /dev/null +++ b/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/support/SqlLog.java @@ -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 statements = Collections.synchronizedList(new ArrayList<>()); + + public void record(String sql) { + if (sql != null) { + statements.add(sql.strip()); + } + } + + public List 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 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(); + } +} diff --git a/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/support/StatementLoggingDataSource.java b/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/support/StatementLoggingDataSource.java new file mode 100644 index 0000000..3d5a59d --- /dev/null +++ b/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/support/StatementLoggingDataSource.java @@ -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. + * + *

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 : ""); + 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 unwrap(Class 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); + } +} diff --git a/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/support/Transcript.java b/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/support/Transcript.java new file mode 100644 index 0000000..01220b7 --- /dev/null +++ b/jdbc-vs-jpa/src/main/java/com/ankurm/jdbcvsjpa/support/Transcript.java @@ -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); + } +} diff --git a/jdbc-vs-jpa/src/main/resources/application.yml b/jdbc-vs-jpa/src/main/resources/application.yml new file mode 100644 index 0000000..c9a5f7d --- /dev/null +++ b/jdbc-vs-jpa/src/main/resources/application.yml @@ -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 diff --git a/jdbc-vs-jpa/src/main/resources/schema.sql b/jdbc-vs-jpa/src/main/resources/schema.sql new file mode 100644 index 0000000..3a53294 --- /dev/null +++ b/jdbc-vs-jpa/src/main/resources/schema.sql @@ -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 +); diff --git a/jdbc-vs-jpa/src/test/java/com/ankurm/jdbcvsjpa/ComparisonTest.java b/jdbc-vs-jpa/src/test/java/com/ankurm/jdbcvsjpa/ComparisonTest.java new file mode 100644 index 0000000..648efd5 --- /dev/null +++ b/jdbc-vs-jpa/src/test/java/com/ankurm/jdbcvsjpa/ComparisonTest.java @@ -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 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 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 orders = jdbcOrderRepository.findByCustomer(customer.id()); + long items = orders.stream().mapToLong(o -> o.getItems().size()).sum(); + return sqlLog.count(); + } +} diff --git a/jdbc-vs-jpa/src/test/java/com/ankurm/jdbcvsjpa/JdbcBehaviorTest.java b/jdbc-vs-jpa/src/test/java/com/ankurm/jdbcvsjpa/JdbcBehaviorTest.java new file mode 100644 index 0000000..1b3cc48 --- /dev/null +++ b/jdbc-vs-jpa/src/test/java/com/ankurm/jdbcvsjpa/JdbcBehaviorTest.java @@ -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 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 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 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 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 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 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"); + } +} diff --git a/jdbc-vs-jpa/src/test/java/com/ankurm/jdbcvsjpa/JpaBehaviorTest.java b/jdbc-vs-jpa/src/test/java/com/ankurm/jdbcvsjpa/JpaBehaviorTest.java new file mode 100644 index 0000000..850f8c7 --- /dev/null +++ b/jdbc-vs-jpa/src/test/java/com/ankurm/jdbcvsjpa/JpaBehaviorTest.java @@ -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 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); + } +} diff --git a/pom.xml b/pom.xml index fdd1fe9..961269c 100644 --- a/pom.xml +++ b/pom.xml @@ -15,9 +15,11 @@ An aggregator only. Each module declares its own spring-boot-starter-parent, deliberately: migration-behavior Spring Boot 4.0.6 / JDK 21: the versions the four published - migration articles were written and verified against. Upgrading it - would silently invalidate output those articles quote. - transactions Spring Boot 4.1.1 / JDK 25, current at the time of writing. + migration articles were written and verified against. Upgrading it + would silently invalidate output those articles quote. + 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 other module moves on. @@ -25,5 +27,6 @@ migration-behavior transactions + jdbc-vs-jpa