Why Spring Data JDBC Aggregate Persistence Fits Domain-Driven Design
Domain-Driven Design draws a hard line around each aggregate: a cluster of objects that changes together, loads together, and is saved as one consistent unit. Spring Data JDBC aggregate persistence takes that line seriously and enforces it at the persistence layer, treating the aggregate root as the only entry point for reading and writing the whole graph. Unlike a full ORM, it has no lazy loading, no first-level cache, and no automatic dirty tracking.
That deliberate simplicity is the point. The mental model collapses to a single rule: load an aggregate, mutate it in memory, then save the root and let the framework reconcile the rows beneath it. Therefore the database access pattern stays predictable, the SQL stays inspectable, and the boundary you drew on a whiteboard survives contact with the codebase.
The Aggregate Root Is the Repository Boundary
In Spring Data JDBC, you create one repository per aggregate root, and only per root. Child entities have no repository of their own because they have no independent lifecycle. For example, an Order owns its OrderLine rows, so you never fetch or delete a line directly; you load the order, adjust its lines, and save the order.
This constraint mirrors the DDD guidance that external references should point only at aggregate roots by identity. Consequently the persistence model and the domain model agree by construction, rather than drifting apart over time. If you have explored hexagonal architecture with ports and adapters in Spring Boot, this repository-per-aggregate rule slots naturally into the driven-adapter side of that design.
Modeling an Order Aggregate in Java
Consider an Order root that holds a collection of lines, a value-object shipping address, and a version field for optimistic locking. The root carries the identity; the lines are mapped as an owned collection. Notably, the child entity needs no @Id that you manage yourself when the relationship is keyed by the parent.
@Table("orders")
public class Order {
@Id
private Long id;
@Version
private Long version;
private String customerRef;
private OrderStatus status;
// Value object stored inline via naming strategy
@Embedded.Nullable(prefix = "ship_")
private Address shippingAddress;
// Owned collection: child rows keyed by order_id
@MappedCollection(idColumn = "order_id", keyColumn = "line_no")
private List<OrderLine> lines = new ArrayList<>();
public void addLine(String sku, int qty, Money price) {
if (status != OrderStatus.DRAFT) {
throw new IllegalStateException("Cannot modify a placed order");
}
lines.add(new OrderLine(sku, qty, price));
}
public void place() {
if (lines.isEmpty()) {
throw new IllegalStateException("Order has no lines");
}
this.status = OrderStatus.PLACED;
}
}
The place() method enforces an invariant inside the aggregate, exactly where DDD wants it. Because there is no proxy or lazy collection, the lines list is a plain, fully materialized ArrayList. Furthermore, the absence of dirty tracking means your domain logic never accidentally triggers a hidden flush.
Value Objects, Embedded Types, and Mapped Collections
Value objects such as Address or Money have no identity and should be stored inline with their owner. The @Embedded annotation flattens a value object’s fields into the parent table, optionally with a column prefix. For example, Address becomes ship_street, ship_city, and ship_postcode columns on the orders table.
For owned collections, @MappedCollection declares the foreign-key column and, when you want ordered lists, a key column. Spring Data JDBC then writes the child rows with a back-reference to the root’s id. Moreover, when you remove an element and save the root, the framework issues the corresponding DELETE automatically, because the children live and die with the aggregate.
References Across Aggregates with AggregateReference
When one aggregate must point at another, you do not embed the foreign object; you store its identity. Spring Data JDBC models this with AggregateReference, a typed wrapper around the referenced root’s id. For instance, an Order might hold an AggregateReference<Customer, Long> rather than a Customer instance.
This keeps each save scoped to a single aggregate and prevents accidental cascade across boundaries. In contrast, a JPA @ManyToOne tempts you into navigating and mutating a neighbor through the same transaction. Consequently, cross-aggregate consistency becomes an explicit application concern, which aligns with patterns like those in event sourcing and CQRS, where eventual consistency between aggregates is the norm rather than the exception.
Optimistic Locking and Custom Mapping
Annotating a field with @Version enables optimistic locking: each update includes the version in its WHERE clause and increments it, so a stale write fails fast with an OptimisticLockingFailureException. Additionally, the presence of a version column lets Spring Data JDBC distinguish a new aggregate from an existing one without a prior SELECT. The Spring Data Relational reference documents the supported lifecycle callbacks and version semantics in detail.
When the default naming strategy is not enough, you can register custom converters or supply a dialect-aware NamingStrategy. For complex projections, a read model built with a plain JdbcTemplate and a custom RowMapper sits comfortably alongside the aggregate repositories. This pairs well with a clean module boundary; if you structure features as packages, the approach complements a Spring Modulith modular monolith where each module owns its tables.
When JDBC Beats JPA, and When It Does Not
Spring Data JDBC shines when aggregate boundaries are clear, the object graph is shallow, and you value predictable SQL over graph navigation. Because every save rewrites the owned children, it is ideal for small-to-medium aggregates that you load and persist as a whole. A common pattern in production teams is to reach for JDBC precisely to escape the surprises of lazy loading and the N+1 query problem.
However, JPA remains the better tool when you need deep, partially loaded object graphs, second-level caching, or rich polymorphic inheritance mapping. If your domain genuinely benefits from lazy associations across a wide graph, fighting Spring Data JDBC to simulate them is the wrong trade. Choose the tool whose mental model matches your domain, not the one with the most features.
In conclusion, Spring Data JDBC aggregate persistence gives Domain-Driven Design a persistence layer that honors aggregate boundaries instead of eroding them. By dropping lazy loading and dirty tracking, it offers a simpler, more honest mental model, predictable SQL, and a one-repository-per-root rule that keeps your code and your domain diagram in agreement. Reach for it when boundaries are crisp, and keep JPA in reserve for the genuinely complex object graphs where it still earns its keep.