Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Spring Data JPA Deep Dive

Entity mapping that scales: relationship pitfalls, diagnosing and fixing the N+1 problem, derived queries versus Specifications, pagination that stays fast, and JPA auditing.

Intermediate8 min readUpdated
On this page

Spring Data JPA removes so much boilerplate that it is easy to forget SQL is still being generated, and that the generator is making decisions on your behalf. The mapping choices below are the ones that decide whether an endpoint runs in five milliseconds or five hundred.

Key Takeaways

  • Make every association LAZY and fetch deliberately. The JPA defaults are wrong for applications at scale.
  • The N+1 problem is the single most common JPA performance bug — and the easiest to assert against in a test.
  • @EntityGraph solves N+1 declaratively; a JOIN FETCH query solves it explicitly. Both beat EAGER.
  • Derived query methods are great until they are not; switch to Specification when filters become dynamic.
  • Page costs an extra COUNT query. Use Slice when the UI only needs "is there more".

Mapping decisions that matter

Order.java
@Entity
@Table(name = "orders", indexes = @Index(name = "ix_orders_customer", columnList = "customer_id"))
public class Order {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @Column(nullable = false, unique = true, length = 32)
    private String reference;
 
    // Always STRING. ORDINAL stores the position in the enum, so inserting a
    // new constant in the middle silently rewrites the meaning of every row.
    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private OrderStatus status;
 
    // The spec default here is EAGER. Override it, every time.
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "customer_id", nullable = false)
    private Customer customer;
 
    // orphanRemoval deletes a line when it is removed from the collection.
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderLine> lines = new ArrayList<>();
 
    @Embedded
    private Money total;
 
    // Keep both sides of a bidirectional association in sync in one place,
    // rather than trusting every caller to remember.
    public void addLine(OrderLine line) {
        lines.add(line);
        line.setOrder(this);
    }
}

Two notes on identifiers. GenerationType.IDENTITY disables JDBC batch inserts, because Hibernate must round-trip to read each generated key. If you insert in bulk, use SEQUENCE with an allocation size. And avoid random UUID primary keys on large tables — they scatter writes across the B-tree index. If you need UUIDs, use a time-ordered variant such as UUIDv7.

The N+1 problem

One query for the list, then one more per row. Latency scales with page size instead of staying flat.

The fix depends on how often you need the association.

@EntityGraph when a specific repository method always needs it:

OrderRepository.java
public interface OrderRepository extends JpaRepository<Order, Long>,
                                         JpaSpecificationExecutor<Order> {
 
    @EntityGraph(attributePaths = { "customer", "lines" })
    List<Order> findByStatus(OrderStatus status);
 
    // JOIN FETCH gives the same result with the join written out. Note the
    // countQuery: without it, pagination with a fetch join is done in memory.
    @Query(value = """
            select distinct o from Order o
            join fetch o.customer c
            where c.country = :country
            """,
           countQuery = "select count(o) from Order o where o.customer.country = :country")
    Page<Order> findByCountry(@Param("country") String country, Pageable pageable);
}

@BatchSize when the access pattern varies. Hibernate then loads lazy associations in batches of n instead of one at a time, turning 21 queries into 3:

Customer.java
@Entity
@BatchSize(size = 25)
public class Customer { }

DTO projections when you only need a few columns — the fastest option, because nothing becomes a managed entity:

OrderSummary.java
public interface OrderSummary {
    String getReference();
    OrderStatus getStatus();
    String getCustomerName();   // resolves customer.name via property path
}
 
// in the repository
List<OrderSummary> findByStatus(OrderStatus status);

Catch regressions in a test rather than in production:

QueryCountTest.java
@Test
void listingOrdersUsesOneQuery() {
    var stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
    stats.clear();
 
    orderService.recentOrders(PageRequest.of(0, 20));
 
    assertThat(stats.getPrepareStatementCount()).isLessThanOrEqualTo(2);
}

Derived queries and their limits

Spring Data builds a query from the method name: findByStatusAndCreatedAtAfterOrderByTotalDesc. The vocabulary is wide — Between, In, Containing, IgnoreCase, IsNull, GreaterThan, StartingWith, True, Distinct, Top10.

The limit is combinatorial. A search screen with six optional filters needs sixty-four method names, so at three or four optional criteria, switch to Specification:

OrderSpecifications.java
public final class OrderSpecifications {
 
    public static Specification<Order> hasStatus(OrderStatus status) {
        return status == null ? null
                : (root, query, cb) -> cb.equal(root.get("status"), status);
    }
 
    public static Specification<Order> createdAfter(Instant when) {
        return when == null ? null
                : (root, query, cb) -> cb.greaterThan(root.get("createdAt"), when);
    }
 
    public static Specification<Order> inCountry(String country) {
        return country == null ? null : (root, query, cb) -> {
            // Avoid a duplicate join when the same association is already joined.
            var customer = root.<Order, Customer>join("customer", JoinType.INNER);
            return cb.equal(customer.get("country"), country);
        };
    }
}
 
// usage — null specifications are ignored by Specification.allOf
var spec = Specification.allOf(
        OrderSpecifications.hasStatus(filter.status()),
        OrderSpecifications.createdAfter(filter.from()),
        OrderSpecifications.inCountry(filter.country()));
 
Page<Order> page = orderRepository.findAll(spec, pageable);

Returning null from a factory when the filter is absent is the idiom that makes optional criteria compose cleanly.

Pagination

Page<T> runs a second COUNT query to compute totalElements. On a large table with a complex WHERE, that count can cost more than the page itself. Slice<T> skips it — it fetches size + 1 rows and reports only whether more exist, which is all an infinite-scroll UI needs.

For deep pagination, offsets are the real problem: OFFSET 100000 makes the database walk and discard a hundred thousand rows. Keyset pagination scales flat:

KeysetPagination.java
@Query("""
       select o from Order o
       where o.createdAt < :cursor
       order by o.createdAt desc
       """)
List<Order> findPageAfter(@Param("cursor") Instant cursor, Pageable pageable);

Sorting must be deterministic, or rows shift between pages. If createdAt is not unique, sort by createdAt then id.

Auditing

Auditable.java
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class Auditable {
 
    @CreatedDate  @Column(updatable = false) private Instant createdAt;
    @LastModifiedDate                        private Instant updatedAt;
    @CreatedBy    @Column(updatable = false) private String createdBy;
    @LastModifiedBy                          private String updatedBy;
 
    @Version
    private Long version;    // optimistic locking
}
 
@Configuration
@EnableJpaAuditing
class AuditConfig {
    @Bean
    AuditorAware<String> auditorAware() {
        return () -> Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
                .filter(Authentication::isAuthenticated)
                .map(Authentication::getName);
    }
}

@Version is worth adding even if you have no concurrency requirement today. It costs one column and turns a silent lost update into an OptimisticLockingFailureException you can retry or surface as a 409.

Cascading, and why to use it sparingly

CascadeType.ALL is the setting people reach for and regret. It propagates persist, merge, remove, refresh and detach across an association, which is exactly right for a genuine composition — order lines have no meaning without their order — and dangerous everywhere else. Cascading REMOVE from an order to its customer means deleting one order deletes the customer, and every other order they ever placed. The rule that keeps you safe: cascade only from an aggregate root to the things it owns, never across an association to a shared entity.

orphanRemoval is subtly different from CascadeType.REMOVE and the distinction is worth holding onto. REMOVE propagates when you delete the parent. orphanRemoval fires when a child is taken out of the collection while the parent lives on — removing a line from an order deletes that line row. Together they express "this collection is the child's entire reason to exist", which is the only situation where either belongs.

Where the persistence context bites

The first-level cache is per transaction and per EntityManager. Inside one transaction, loading the same row twice returns the same instance and issues one query — a useful optimisation that occasionally becomes a surprise, because a query issued after a modification may not see the change until Hibernate flushes.

The related trap is the LazyInitializationException. A lazy association can only be resolved while the persistence context is open. Return an entity from a @Transactional service, let the transaction close, and touch a lazy field in the controller, and you get an exception that names a proxy rather than the actual mistake. The advice spring.jpa.open-in-view=true — on by default — hides this by keeping the context open for the whole request, which is why so few developers meet the exception and why so many applications quietly issue queries from their view layer. Turn it off, map to DTOs inside the transaction, and the boundary becomes explicit.

Bulk operations bypass the context entirely. A @Modifying update statement changes rows in the database without touching loaded entities, so anything already in memory is now stale. Add clearAutomatically = true and flushAutomatically = true to the annotation, or perform bulk work in its own transaction.

Transactions and the persistence context

Spring Data marks read methods @Transactional(readOnly = true), which lets Hibernate skip dirty checking and lets the driver route to a replica. Your own service methods should do the same deliberately.

The other thing worth internalising: inside a transaction, a managed entity is dirty-checked at flush. You do not need to call save() after modifying a loaded entity — the update happens anyway. Calling save() is harmless but misleading, because it suggests the change would not persist otherwise.

What to take away

Set every association lazy, then fetch what you need with an entity graph or a projection. Assert on query counts so N+1 cannot come back. Use Specification once filters get dynamic, Slice when you do not need a total, and keyset pagination once offsets get deep.

Frequently Asked Questions

How do I actually detect an N+1 problem?
Turn on spring.jpa.properties.hibernate.generate_statistics in a test profile and assert on the query count, or add a datasource-proxy that logs the count per request. Reading SQL logs by eye works once; asserting on the count in a test stops the problem coming back.
Why does @ManyToOne default to EAGER?
The JPA specification says so, and it predates the scale most applications now run at. It is almost always wrong: every load of the child drags in the parent whether you need it or not. Set fetch = FetchType.LAZY on every @ManyToOne and @OneToOne and fetch explicitly when you need the association.
Should I use List or Set for @ManyToMany?
Set. With a List, Hibernate deletes every row of the join table and reinserts them whenever the collection changes, because a bag has no stable identity. With a Set it issues a single delete or insert for the actual change.

Related tutorials