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

2.7 KiB

05 — The aggregate boundary

← 04 the JDBC side · next: when this gets expensive →

Source: jdbc/Order.java (the AggregateReference<Customer, Long> customer field). Test: JdbcBehaviorTest#c_aggregateReferenceDoesNotLoadTheCustomer, transcript 08.

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 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'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.