Spring Data JDBC for Simpler Data Access Without Lazy Loading
Spring Data JDBC offers Java teams a lighter, aggregate-oriented alternative to Hibernate. It keeps familiar Spring Data repositories, JDBC-backed transactions and SQL visibility while avoiding proxy-managed relationships. For a suitable application foundation, the Spring Boot setup guide provides useful configuration context before introducing the persistence model.
Lazy loading can make a data layer appear simple until production traffic exposes its costs. A Hibernate proxy may trigger extra queries as child records are accessed, producing N+1 selection patterns, while access outside an open session can fail with LazyInitializationException. The deeper problem is hidden I/O and an object lifetime that is difficult to reason about.
The JDBC model loads an aggregate and its contained records before the repository method returns. It may use several statements, but they complete during the call rather than surprising the caller later. That makes object access predictable and allows plain Java objects to travel safely through service and API layers.
For Australian teams, the appeal is often operational. Sydney and Melbourne consultancies maintain business systems where straightforward maintenance matters more than a large persistence framework. A Brisbane product team can keep one aggregate per operation, while a Perth support group works from the same objects. Across Australian time zones, fewer deferred round trips help during handovers and incident response.
Why lazy loading changes the design
Relational data rarely matches the shape of an application model. An order contains lines, and a customer contains orders, but those links often require joins and different loading decisions. A persistence proxy can hide that boundary, allowing code to look natural until query volume or session management becomes a production concern.
Spring Data JDBC makes the boundary explicit: an aggregate root owns a consistency boundary, and its children are loaded as part of that aggregate. There are no lazy child proxies to initialise. The benefit is not fewer SQL statements in every case; loading happens at a defined repository boundary and remains visible in tests and database monitoring.
Mapping aggregates as Java objects
Start with the aggregate root, not a matching database table. An order owns its lines, while a customer reference identifies another aggregate without embedding the customer’s full object graph.
@Table("customer_order")
public class CustomerOrder {
@Id
private Long id;
private String reference;
private List<OrderLine> lines = new ArrayList<>();
public void addLine(OrderLine line) {
lines.add(line);
}
}
interface CustomerOrderRepository
extends CrudRepository<CustomerOrder, Long> {}
The repository stays deliberately small. A findById operation loads the order and its lines before returning, while save writes the aggregate as one logical unit. Deleting it also removes its contained lines. This removes proxy initialisation from the request path, although SQL indexes and the number of child collections still influence performance.
Keep references to other aggregates as identifiers or aggregate references rather than embedded object graphs. This preserves clear ownership boundaries and makes the effect of updates and deletions predictable.
Comparing access models in practice
The choice depends on required persistence behaviour, not annotation preference. Teams should compare how each model handles loading, writes, queries and relationships before selecting an abstraction.
| Concern | Spring Data JDBC | JPA with Hibernate |
|---|---|---|
| Aggregate access | Loads aggregate contents before returning; no lazy proxies | Often exposes lazy associations through a persistence context |
| Detecting changes | Saves are explicit; no automatic dirty checking | Can track entity changes during an open session |
| Query options | Derived queries, @Query and Querydsl |
Rich criteria, specifications and mapping options |
| Relationship writes | Child records follow explicit aggregate rules | Cascades and fetch plans can be configured in detail |
| Operational trade-off | Smaller concept surface and clearer transactions | More features, with greater tuning and runtime complexity |
Spring Data JDBC is usually the cleaner fit for straightforward aggregates, APIs and CRUD services. JPA remains appropriate when advanced dynamic queries, mapped inheritance or varied persistence strategies are core requirements. A proof of concept should use representative records and relationships rather than toy data.
Running Spring Data JDBC in production
Production still needs disciplined schema and transaction design. Use Flyway or Liquibase for migrations, index the columns used by custom queries, and place multi-aggregate writes in explicit @Transactional service methods. Optimistic locking with @Version can protect concurrent updates without adding a persistence context.
Australian billing flows also reward careful money handling. Store A$ amounts in integer cents, record whether GST is included, and map exported totals to the MYOB or Xero formats used by local accounting systems. Avoid floating-point values for totals and define rounding rules before invoices reach production.
Payment integrations add another aggregate boundary. Australian checkout flows may combine cards through Stripe or PayPal with buy-now-pay-later providers such as Afterpay, so provider references and webhook outcomes need an explicit model rather than an accidental cascade. A quick arvo handover is easier when diagrams show which records share a transaction.
A pragmatic rollout checklist
Adoption works best when the team agrees on conventions before replacing a large persistence layer. These checks keep the model visible, repeatable and easy to review:
- Map aggregate boundaries to business transactions, not database-table convenience.
- Load complete aggregates where services need them and verify query counts in tests.
- Wrap related writes in explicit
@Transactionalservice methods. - Use DTO projections for read-only joins and reporting queries.
- Keep schema migrations, indexes and constraints under version control.
- Document save, delete and reference semantics for every repository.
With those conventions in place, services can load complete aggregates, change ordinary Java objects and persist deliberate updates. The result is a smaller persistence surface with predictable database behaviour and fewer session-related surprises.