Building Robust Persistence with Spring

Most 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

  1. 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
  1. Control flush timing: Flushing does not mean committing. A flush can happen:

For write-heavy code paths, delaying flushes avoids unnecessary SQL.

entityManager.setFlushMode(FlushModeType.COMMIT);
  1. 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

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

  1. Define transaction boundaries at the service layer: Repositories should not own transactions. Services express business intent.
  2. Avoid transactional self-invocation: Spring uses proxies. Calling another @Transactional method in the same class bypasses the proxy.
this.inner(); // no transaction semantics applied

Split responsibilities into separate beans.

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

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:

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:

It avoids persistence context overhead entirely.

When to use R2DBC

R2DBC is not “reactive JPA”. It is a different paradigm.

Use it when:

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:

Avoid forcing JPA-style modeling onto document databases.


Conclusion

Going beyond Spring Data JPA does not mean abandoning it. It means understanding:

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.