Building Robust Persistence with Spring
19 Mar 2026Most Spring projects start their persistence story with Spring Data JPA repositories. That is usually the right choice, but many projects stagnate there. Performance issues, unclear transaction behavior, and “mysterious” bugs often come not from JPA itself, but from an incomplete mental model of how it actually works inside the Spring Framework.
This article goes beyond CRUD repositories and focuses on what developers can actively do to improve correctness, performance, and maintainability.
1. JPA Persistence Context and Dirty Checking
At the heart of JPA is the persistence context. It is essentially a first-level cache that tracks managed entities within a transaction.
@Transactional
public void updateEmail(Long userId, String email) {
User user = entityManager.find(User.class, userId);
user.setEmail(email);
}
There is no save() call here, yet the update will be flushed to the database. This happens through dirty checking: JPA compares the entity’s current state with the snapshot it took when the entity was loaded.
What developers can do better
- Understand what is managed: Only entities loaded within the persistence context are tracked. Detached entities are not dirty-checked.
User user = userRepository.findById(id).get();
entityManager.detach(user);
user.setEmail("x@y.com"); // no update
- Control flush timing: Flushing does not mean committing. A flush can happen:
- Before query execution
- At transaction commit
- Explicitly via
entityManager.flush()
For write-heavy code paths, delaying flushes avoids unnecessary SQL.
entityManager.setFlushMode(FlushModeType.COMMIT);
- Avoid unintended updates: Changing fields on managed entities inside read-only logic is a common source of bugs.
@Transactional(readOnly = true)
public UserDto getUser(Long id) {
User user = userRepository.findById(id).get();
user.setLastAccess(Instant.now()); // still flushed in many JPA providers
}
If it should not update, map to DTOs early or detach entities.
2. Transaction Boundaries and Propagation
Spring’s @Transactional is deceptively simple. The real power (and danger) lies in transaction propagation.
Default behavior
@Transactional
public void outer() {
inner();
}
@Transactional
public void inner() {
// joins the same transaction
}
Both methods run in the same transaction.
Common propagation modes
REQUIRED(default): join or createREQUIRES_NEW: suspend current, start newMANDATORY: fail if none existsNOT_SUPPORTED: suspend transaction
Real-world example
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order);
auditService.logOrder(order); // should never rollback order
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logOrder(Order order) {
auditRepository.save(new AuditLog(order));
}
Now, even if audit logging fails, the order transaction can still commit.
What developers can do better
- Define transaction boundaries at the service layer: Repositories should not own transactions. Services express business intent.
- Avoid transactional self-invocation: Spring uses proxies. Calling another
@Transactionalmethod in the same class bypasses the proxy.
this.inner(); // no transaction semantics applied
Split responsibilities into separate beans.
- Be explicit about read-only: It enables optimizations at both Spring and JPA levels.
@Transactional(readOnly = true)
public List<User> findActiveUsers() { ... }
3. The N+1 Problem (and Real Fixes)
The N+1 problem is not folklore, it is deterministic behavior caused by lazy loading.
List<Order> orders = orderRepository.findAll();
for (Order order : orders) {
System.out.println(order.getCustomer().getName());
}
One query for orders, N queries for customers.
Real fixes (not myths)
Myth: “Just make everything EAGER” This leads to Cartesian explosions and memory waste.
Fix 1: Fetch joins
@Query("""
select o from Order o
join fetch o.customer
""")
List<Order> findAllWithCustomer();
This is the most reliable fix for read use cases.
Fix 2: Entity graphs
@EntityGraph(attributePaths = "customer")
List<Order> findAll();
Cleaner than fetch joins when you want reuse without query duplication.
Fix 3: Batch fetching (Hibernate-specific)
With Hibernate, batching reduces N queries into fewer queries.
hibernate.default_batch_fetch_size=50
This does not eliminate N+1, but makes it N / batch_size + 1.
What developers can do better
- Profile SQL early (P6Spy, datasource-proxy)
- Design queries around read models, not entities
- Use DTO projections when relationships are deep
record OrderView(Long id, String customerName) {}
@Query("""
select new com.example.OrderView(o.id, c.name)
from Order o join o.customer c
""")
List<OrderView> findOrderViews();
4. Choosing the Right Persistence Tool
JPA is powerful, but it is not universal. Mature projects mix persistence approaches intentionally.
When to use JPA
Use JPA when:
- You need complex domain modeling
- Aggregates and invariants matter
- Writes and consistency are important
JPA shines when entities reflect business concepts, not tables.
When to use JDBC Template
jdbcTemplate.query(
"select id, total from orders where status = ?",
mapper,
"PAID"
);
Use JDBC Template when:
- Queries are complex and hand-tuned
- You need predictable SQL
- Bulk operations dominate
It avoids persistence context overhead entirely.
When to use R2DBC
R2DBC is not “reactive JPA”. It is a different paradigm.
Use it when:
- You have high concurrency with IO-bound workloads
- End-to-end reactive stacks are required
- You accept simpler mapping models
Do not mix blocking JPA calls into reactive pipelines.
When to use NoSQL integrations
Spring integrates well with MongoDB, Redis, Cassandra, and others.
Use NoSQL when:
- Schema flexibility is required
- Reads dominate and denormalization is acceptable
- Horizontal scaling matters more than transactions
Avoid forcing JPA-style modeling onto document databases.
Conclusion
Going beyond Spring Data JPA does not mean abandoning it. It means understanding:
- How the persistence context really behaves
- Where transactions begin and end
- Why N+1 happens and how to eliminate it
- When not to use JPA at all
Teams that master these areas write systems that scale predictably, fail gracefully, and remain understandable years later. Spring gives you the tools, but disciplined usage is what turns them into robust architecture.