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.
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
LAZYand 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.
@EntityGraphsolves N+1 declaratively; aJOIN FETCHquery solves it explicitly. Both beatEAGER.- Derived query methods are great until they are not; switch to
Specificationwhen filters become dynamic. Pagecosts an extraCOUNTquery. UseSlicewhen the UI only needs "is there more".
Mapping decisions that matter
@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
The fix depends on how often you need the association.
@EntityGraph when a specific repository method always needs it:
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:
@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:
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:
@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:
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:
@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
@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?
Why does @ManyToOne default to EAGER?
Should I use List or Set for @ManyToMany?
Related tutorials
- Scheduling & Async ProcessingScheduled tasks and async methods done properly: fixedRate versus fixedDelay, sizing executors, exception handling that does not silently swallow, and distributed locking with ShedLock.
- Redis with Spring BootRedis beyond caching: choosing the right data structure, distributed locks that are actually safe, Redis Streams as a queue, and configuring Lettuce for Sentinel and Cluster.
- Caching Strategies in Spring BootSpring cache abstraction in practice: @Cacheable key design, choosing between Caffeine and Redis, per-cache TTLs, cache stampedes, and a two-level cache that survives a Redis outage.
- Validation & Data IntegrityJakarta Bean Validation in Spring Boot: the full constraint set, custom validators, validation groups, cross-field rules, method validation and where each layer belongs.