Files
sdjpa4-demo/jdbc-vs-jpa/docs/05-the-aggregate-boundary.md
T
asmhatre 448c383879 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.
2026-09-17 19:09:35 +00:00

39 lines
2.7 KiB
Markdown

# 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).