Java & Spring Boot6 min read·By Liyabona Saki··

Spring Data JPA — 10 Best Practices for Production

Avoid N+1, lazy loading traps, and slow startup. The 10 JPA practices that separate hobby apps from production systems.

The Spring Data JPA pitfalls that I keep finding in code review

JPA is a productivity accelerator and a foot-gun in equal measure. The same magic that lets you write userRepo.findById(id) and get back a populated entity will, in the wrong configuration, also fire 1+N queries inside a Jackson serializer at 3am. This article is the set of rules I apply when reviewing a Spring Data JPA codebase — what to keep, what to throw out, what to be paranoid about.

Rule 1: never return entities from a controller

```java
// don't
@GetMapping("/{id}")
User get(@PathVariable Long id) { return repo.findById(id).orElseThrow(); }

// do @GetMapping("/{id}") UserResponse get(@PathVariable Long id) { return repo.findById(id).map(UserResponse::from).orElseThrow(); } ```

Three reasons:

1. Lazy fields serialize unpredictably. Jackson touches the field, JPA fires a query, suddenly your GET is N+1. 2. Schema changes leak. Add a column, it appears in your JSON response without a code change. Reviewers will not catch it. 3. You lose the chance to evolve the API independently of the schema. A DTO is a contract; an entity is an implementation detail.

The DTO mapping feels like overhead. It is the cheapest insurance you will ever buy.

Rule 2: fetch joins, not Eager

Marking a relationship FetchType.EAGER is convenient until the day you write a list endpoint and load 50 users plus all of each user's posts plus all of each post's comments. The query plan is appalling and you cannot turn it off for that one call site.

Keep relationships LAZY by default and use explicit fetch joins where you need them:

java
@Query("select u from User u left join fetch u.roles where u.id = :id")
Optional<User> findByIdWithRoles(@Param("id") Long id);

Or, more flexibly, an EntityGraph:

java
@EntityGraph(attributePaths = {"roles", "department"})
Optional<User> findById(Long id);

The discipline is "every endpoint declares its fetch graph". Once you adopt it, N+1 bugs go away as a class.

Rule 3: `@Transactional` belongs on the service, never the controller, never the repository

The repository is a transactional boundary internally; layering another transaction over it does nothing. The controller has no business owning transactional semantics — that couples HTTP to persistence.

The service layer is the right home, with two annotations that handle 95% of cases:

```java
@Service
class OrderService {

@Transactional(readOnly = true) public OrderResponse get(UUID id) { ... }

@Transactional public Order create(CreateOrderCommand cmd) { ... } } ```

readOnly = true is not just a hint — Hibernate skips dirty-checking, which is a meaningful win on read-heavy endpoints.

Rule 4: derived query names are a trap above three conditions

findByLastNameAndFirstNameAndActiveTrueOrderByCreatedAtDesc parses, compiles, runs, and is unreadable. At three or more conditions, write @Query JPQL or a Specification. Future-you will be able to understand the intent.

Rule 5: pagination is not optional on any list endpoint

Every findAll should accept a Pageable. Every list-returning endpoint should cap the page size:

java
@GetMapping
Page<OrderResponse> list(@PageableDefault(size = 20) Pageable page) {
  return repo.findAll(page).map(OrderResponse::from);
}

Without the default, a client passing ?size=10000 happily returns ten thousand rows and crashes your JVM. With it, abuse is bounded.

The size parameter must be validated server-side regardless — I add a global PageableHandlerMethodArgumentResolverCustomizer that caps the maximum at 100.

Rule 6: persist what your DB constrains, not what your code constrains

JPA validation (@NotNull, @Size) on entities is fine, but the database must also have the constraint. If a value is required, both the entity and the column declaration say so. The day someone bypasses the entity layer with a raw query, the database is the only thing keeping the data honest.

Rule 7: indexes belong in the migration, not in `@Index` annotations

Hibernate's schema generation is not what you ship to production. You use Flyway or Liquibase, and indexes are explicit migration files. The @Index annotation on the entity is a comment at best; the DBA who tunes the database does not read your Java.

Rule 8: profile before you optimize

Hibernate has flags that surface every slow path:

yaml
spring:
  jpa:
    properties:
      hibernate:
        generate_statistics: true
        session.events.log.LOG_QUERIES_SLOWER_THAN_MS: 100
logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.orm.jdbc.bind: TRACE

In development, these flags are non-negotiable. You will catch N+1, you will catch missing fetch joins, you will catch the query that fires on every page load and never used to. In production, leave generate_statistics off (it costs CPU) but keep the slow-query log on at a higher threshold.

Rule 9: the second-level cache is rarely worth it

Hibernate's second-level cache (@Cacheable at the entity level with Ehcache or similar) sounds great and is almost always the wrong answer. Cache invalidation across instances is hard, the cache shape is entity-shaped (which couples it to your schema), and the performance win is usually smaller than just caching DTOs in Redis at the service layer.

I have removed second-level caches more often than I have added them.

Rule 10: when JPA is the wrong tool, leave it

Some workloads — bulk imports, complex analytical queries, reporting — are miserable in JPA. The discipline is to recognise them and reach for raw JDBC, jOOQ, or JdbcTemplate without guilt. JPA is wonderful for "load an aggregate, mutate it, save it". It is bad at "stream a million rows into another table".

A healthy Spring Boot codebase has both: JPA for the aggregate-shaped CRUD that is 80% of the app, and a thin layer of raw SQL for the 20% where the abstraction is fighting you.

The closing test

Before merging a PR with persistence code, I ask three questions: *what does the generated SQL look like under load*, *what happens if this method is called inside a loop*, and *what does this look like at 10x the current row count*. If the answer to any of those is "I don't know", the PR is not ready.

Go deeper

Further reading

Source Code

Get the full project on GitHub

View repo →
#JPA#Hibernate#Spring Boot#Performance

Stay in the Loop

Get the next tutorial in your inbox

Continue reading in Java & Spring Boot

API Rate Limiting in Spring Boot with Bucket4j and Redis

Protect your APIs from abuse with per-user and per-IP rate limiting using Bucket4j, Redis and a clean filter-based implementation.

Related tutorials