JPA Patterns Skill
Best practices and common pitfalls for JPA/Hibernate in Spring applications.
When to Use
- User mentions "N+1 problem" / "too many queries"
- LazyInitializationException errors
- Questions about fetch strategies (EAGER vs LAZY)
- Transaction management issues
- Entity relationship design
- Query optimization
Quick Reference: Common Problems
| Problem | Symptom | Solution |
|---|---|---|
| N+1 queries | Many SELECT statements | JOIN FETCH, @EntityGraph |
| LazyInitializationException | Error outside transaction | Open Session in View, DTO projection, JOIN FETCH |
| Slow queries | Performance issues | Pagination, projections, indexes |
| Dirty checking overhead | Slow updates | Read-only transactions, DTOs |
| Lost updates | Concurrent modifications | Optimistic locking (@Version) |
N+1 Problem
The #1 JPA performance killer
The Problem
// ❌ BAD: N+1 queries
@Entity
public class Author {
@Id private Long id;
private String name;
@OneToMany(mappedBy = "author", fetch = FetchType.LAZY)
private List<Book> books;
}
// This innocent code...
List<Author> authors = authorRepository.findAll(); // 1 query
for (Author author : authors) {
System.out.println(author.getBooks().size()); // N queries!
}
// Result: 1 + N queries (if 100 authors = 101 queries)
Solution 1: JOIN FETCH (JPQL)
// ✅ GOOD: Single query with JOIN FETCH
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query("SELECT a FROM Author a JOIN FETCH a.books")
List<Author> findAllWithBooks();
}
// Usage - single query
List<Author> authors = authorRepository.findAllWithBooks();
Solution 2: @EntityGraph
// ✅ GOOD: EntityGraph for declarative fetching
public interface AuthorRepository extends JpaRepository<Author, Long> {
@EntityGraph(attributePaths = {"books"})
List<Author> findAll();
// Or with named graph
@EntityGraph(value = "Author.withBooks")
List<Author> findAllWithBooks();
}
// Define named graph on entity
@Entity
@NamedEntityGraph(
name = "Author.withBooks",
attributeNodes = @NamedAttributeNode("books")
)
public class Author {
// ...
}
Solution 3: Batch Fetching
// ✅ GOOD: Batch fetching (Hibernate-specific)
@Entity
public class Author {
@OneToMany(mappedBy = "author")
@BatchSize(size = 25) // Fetch 25 at a time
private List<Book> books;
}
// Or globally in application.properties
spring.jpa.properties.hibernate.default_batch_fetch_size=25
Detecting N+1
# Enable SQL logging to detect N+1
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
Lazy Loading
FetchType Basics
@Entity
public class Order {
// LAZY: Load only when accessed (default for collections)
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items;
// EAGER: Always load immediately (default for @ManyToOne, @OneToOne)
@ManyToOne(fetch = FetchType.EAGER) // ⚠️ Usually bad
private Customer customer;
}
Best Practice: Default to LAZY
// ✅ GOOD: Always use LAZY, fetch when needed
@Entity
public class Order {
@ManyToOne(fetch = FetchType.LAZY) // Override EAGER default
private Customer customer;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items;
}
LazyInitializationException
// ❌ BAD: Accessing lazy field outside transaction
@Service
public class OrderService {
public Order getOrder(Long id) {
return orderRepository.findById(id).orElseThrow();
}
}
// In controller (no transaction)
Order order = orderService.getOrder(1L);
order.getItems().size(); // 💥 LazyInitializationException!
Solutions for LazyInitializationException
Solution 1: JOIN FETCH in query
// ✅ Fetch needed associations in query
@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.id = :id")
Optional<Order> findByIdWithItems(@Param("id") Long id);
Solution 2: @Transactional on service method
// ✅ Keep transaction open while accessing
@Service
public class OrderService {
@Transactional(readOnly = true)
public OrderDTO getOrderWithItems(Long id) {
Order order = orderRepository.findById(id).orElseThrow();
// Access within transaction
int itemCount = order.getItems().size();
return new OrderDTO(order, itemCount);
}
}
Solution 3: DTO Projection (recommended)
// ✅ BEST: Return only what you need
public interface OrderSummary {
Long getId();
String getStatus();
int getItemCount();
}
@Query("SELECT o.id as id, o.status as status, SIZE(o.items) as itemCount " +
"FROM Order o WHERE o.id = :id")
Optional<OrderSummary> findOrderSummary(@Param("id") Long id);
Solution 4: Open Session in View (not recommended)
# Keeps session open during view rendering
# ⚠️ Can mask N+1 problems, use with caution
spring:
jpa:
open-in-view: true # Default is true
Transactions
Basic Transaction Management
@Service
public class OrderService {
// Read-only: Optimized, no dirty checking
@Transactional(readOnly = true)
public Order findById(Long id) {
return orderRepository.findById(id).orElseThrow();
}
// Write: Full transaction with dirty checking
@Transactional
public Order createOrder(CreateOrderRequest request) {
Order order = new Order();
// ... set properties
return orderRepository.save(order);
}
// Explicit rollback
@Transactional(rollbackFor = Exception.class)
public void processPayment(Long orderId) throws PaymentException {
// Rolls back on any exception, not just RuntimeException
}
}
Transaction Propagation
@Service
public class OrderService {
@Autowired
private PaymentService paymentService;
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order);
// REQUIRED (default): Uses existing or creates new
paymentService.processPayment(order);
// If paymentService throws, entire order is rolled back
}
}
@Service
public class PaymentService {
// REQUIRES_NEW: Always creates new transaction
// If this fails, order can still be saved
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processPayment(Order order) {
// Independent transaction
}
// MANDATORY: Must run within existing transaction
@Transactional(propagation = Propagation.MANDATORY)
public void updatePaymentStatus(Order order) {
// Throws if no transaction exists
}
}
Common Transaction Mistakes
// ❌ BAD: Calling @Transactional method from same class
@Service
public class OrderService {
public void processOrder(Long id) {
updateOrder(id); // @Transactional is IGNORED!
}
@Transactional
public void updateOrder(Long id) {
// Transaction not started because called internally
}
}
// ✅ GOOD: Inject self or use separate service
@Service
public class OrderService {
@Autowired
private OrderService self; // Or use separate service
public void processOrder(Long id) {
self.updateOrder(id); // Now transaction works
}
@Transactional
public void updateOrder(Long id) {
// Transaction properly started
}
}
Entity Relationships
OneToMany / ManyToOne
// ✅ GOOD: Bidirectional with proper mapping
@Entity
public class Author {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(mappedBy = "author", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Book> books = new Arra