| name | jpa-patterns |
| description | JPA/Hibernate patterns and common pitfalls (N+1, lazy loading, transactions, queries). Use when user has JPA performance issues, LazyInitializationException, or asks about entity relationships and fetching strategies. |
JPA Patterns and Best Practices
Quick Reference - Common Problems
| Problem | Symptom | Solution |
|---|
| N+1 Queries | Slow list operations, many SQL queries | JOIN FETCH, @EntityGraph, batch fetching |
| LazyInitializationException | Error accessing lazy field outside transaction | Fetch in query, DTO projection, Open Session in View |
| Detached Entity | save() creates duplicate instead of update | Use findById() + modify, or @Transactional |
| Optimistic Lock | OptimisticLockException | Add @Version, handle retry |
| Cascade Issues | Orphan records, unexpected deletes | Review cascade types, use orphanRemoval |
| Slow Pagination | Full table scan for count | Use keyset pagination, DTO projections |
| Memory Issues | OOM on large result sets | Streaming, pagination, DTO projections |
N+1 Problem
The Problem
@Entity
public class Order {
@ManyToOne(fetch = FetchType.LAZY)
private Customer customer;
}
List<Order> orders = orderRepository.findAll();
for (Order order : orders) {
String name = order.getCustomer().getName();
}
Solution 1: JOIN FETCH
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT o FROM Order o JOIN FETCH o.customer")
List<Order> findAllWithCustomers();
@Query("SELECT o FROM Order o JOIN FETCH o.customer WHERE o.status = :status")
List<Order> findByStatusWithCustomer(@Param("status") OrderStatus status);
}
Solution 2: @EntityGraph
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = {"customer", "items"})
List<Order> findByStatus(OrderStatus status);
@EntityGraph("Order.withCustomerAndItems")
List<Order> findAll();
}
@Entity
@NamedEntityGraph(
name = "Order.withCustomerAndItems",
attributeNodes = {
@NamedAttributeNode("customer"),
@NamedAttributeNode("items")
}
)
public class Order {
@ManyToOne(fetch = FetchType.LAZY)
private Customer customer;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items;
}
Solution 3: Batch Fetching
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 20
@Entity
public class Order {
@OneToMany(mappedBy = "order")
@BatchSize(size = 20)
private List<OrderItem> items;
}
Detecting 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
@Bean
public HibernatePropertiesCustomizer hibernateCustomizer() {
return properties -> properties.put("hibernate.generate_statistics", "true");
}
Lazy Loading
FetchType Basics
@ManyToOne
@OneToOne
@OneToMany
@ManyToMany
Best Practice: Always Use LAZY
@Entity
public class Order {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "shipping_id")
private ShippingInfo shippingInfo;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items = new ArrayList<>();
}
LazyInitializationException Solutions
@GetMapping("/orders/{id}")
public OrderResponse getOrder(@PathVariable Long id) {
Order order = orderRepository.findById(id).orElseThrow();
order.getItems().size();
}
@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.id = :id")
Optional<Order> findByIdWithItems(@Param("id") Long id);
@Query("SELECT new com.example.dto.OrderSummary(o.id, o.status, o.total) FROM Order o WHERE o.id = :id")
Optional<OrderSummary> findOrderSummary(@Param("id") Long id);
@Service
public class OrderService {
@Transactional(readOnly = true)
public OrderResponse getOrder(Long id) {
Order order = orderRepository.findById(id).orElseThrow();
return OrderResponse.from(order);
}
}
Transactions
Basic Management
@Service
public class OrderService {
@Transactional(readOnly = true)
public Order findById(Long id) {
return orderRepository.findById(id).orElseThrow();
}
@Transactional
public Order createOrder(CreateOrderRequest request) {
Order order = new Order();
order.setCustomer(customerRepository.findById(request.customerId()).orElseThrow());
order.setStatus(OrderStatus.CREATED);
return orderRepository.save(order);
}
}
Propagation
@Transactional(propagation = Propagation.REQUIRED)
public void processOrder(Order order) { ... }
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAuditEvent(AuditEvent event) {
auditRepository.save(event);
}
@Transactional(propagation = Propagation.MANDATORY)
public void updateInventory(Order order) {
}
Common Mistakes
@Service
public class OrderService {
public void processOrder(Long orderId) {
createShipment(orderId);
}
@Transactional
public void createShipment(Long orderId) {
}
}
@Service
public class OrderService {
private final ShipmentService shipmentService;
public void processOrder(Long orderId) {
shipmentService.createShipment(orderId);
}
}
@Service
public class ShipmentService {
@Transactional
public void createShipment(Long orderId) {
}
}
@Transactional
public void processOrder(Order order) {
validateOrder(order);
Order saved = orderRepository.save(order);
emailService.sendConfirmation(saved);
analyticsService.trackOrder(saved);
}
@Transactional
public Order saveOrder(Order order) {
validateOrder(order);
return orderRepository.save(order);
}
public void processOrder(Order order) {
Order saved = saveOrder(order);
emailService.sendConfirmation(saved);
analyticsService.trackOrder(saved);
}
Entity Relationships
OneToMany / ManyToOne
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
public void addItem(OrderItem item) {
items.add(item);
item.setOrder(this);
}
public void removeItem(OrderItem item) {
items.remove(item);
item.setOrder(null);
}
}
@Entity
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id", nullable = false)
private Order order;
private String productName;
private int quantity;
private BigDecimal price;
}
ManyToMany - Use Set, Not List
@Entity
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinTable(
name = "student_course",
joinColumns = @JoinColumn(name = "student_id"),
inverseJoinColumns = @JoinColumn(name = "course_id")
)
private Set<Course> courses = new HashSet<>();
public void addCourse(Course course) {
courses.add(course);
course.getStudents().add(this);
}
public void removeCourse(Course course) {
courses.remove(course);
course.getStudents().remove(this);
}
}
@Entity
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToMany(mappedBy = "courses")
private Set<Student> students = new HashSet<>();
}
equals / hashCode for Entities
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NaturalId
@Column(nullable = false, unique = true)
private String sku;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Product product = (Product) o;
return Objects.equals(sku, product.sku);
}
@Override
public int hashCode() {
return Objects.hash(sku);
}
}
@Entity
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
OrderItem that = (OrderItem) o;
return id != null && Objects.equals(id, that.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}
Query Optimization
Pagination
public interface OrderRepository extends JpaRepository<Order, Long> {
Page<Order> findByStatus(OrderStatus status, Pageable pageable);
@Query(value = "SELECT o FROM Order o WHERE o.status = :status",
countQuery = "SELECT COUNT(o.id) FROM Order o WHERE o.status = :status")
Page<Order> findByStatusOptimized(@Param("status") OrderStatus status, Pageable pageable);
Slice<Order> findByCustomerId(Long customerId, Pageable pageable);
}
@Query("SELECT o FROM Order o WHERE o.createdAt < :cursor ORDER BY o.createdAt DESC")
List<Order> findOrdersBefore(@Param("cursor") LocalDateTime cursor, Pageable pageable);
DTO Projections
public interface OrderSummary {
Long getId();
OrderStatus getStatus();
BigDecimal getTotal();
String getCustomerName();
}
public interface OrderRepository extends JpaRepository<Order, Long> {
List<OrderSummary> findByStatus(OrderStatus status);
}
public record OrderSummaryDto(Long id, OrderStatus status, BigDecimal total) {}
@Query("SELECT new com.example.dto.OrderSummaryDto(o.id, o.status, o.total) " +
"FROM Order o WHERE o.status = :status")
List<OrderSummaryDto> findOrderSummaries(@Param("status") OrderStatus status);
Bulk Operations
public interface UserRepository extends JpaRepository<User, Long> {
@Modifying
@Query("UPDATE User u SET u.active = false WHERE u.lastLoginAt < :cutoff")
int deactivateInactiveUsers(@Param("cutoff") LocalDateTime cutoff);
@Modifying
@Query("DELETE FROM User u WHERE u.active = false AND u.createdAt < :cutoff")
int deleteInactiveUsers(@Param("cutoff") LocalDateTime cutoff);
}
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("UPDATE User u SET u.active = false WHERE u.lastLoginAt < :cutoff")
int deactivateInactiveUsers(@Param("cutoff") LocalDateTime cutoff);
Optimistic Locking
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Version
private Long version;
private String name;
private int stockQuantity;
}
@Service
public class ProductService {
@Transactional
public Product updateStock(Long productId, int quantity) {
try {
Product product = productRepository.findById(productId).orElseThrow();
product.setStockQuantity(product.getStockQuantity() - quantity);
return productRepository.save(product);
} catch (OptimisticLockException e) {
throw new ConflictException("Product was modified by another transaction. Please retry.");
}
}
@Retryable(value = OptimisticLockException.class, maxAttempts = 3)
@Transactional
public Product updateStockWithRetry(Long productId, int quantity) {
Product product = productRepository.findById(productId).orElseThrow();
product.setStockQuantity(product.getStockQuantity() - quantity);
return productRepository.save(product);
}
}
Common Mistakes
Cascade Misuse
@Entity
public class Order {
@ManyToOne(cascade = CascadeType.ALL)
private Customer customer;
}
@Entity
public class Order {
@ManyToOne(fetch = FetchType.LAZY)
private Customer customer;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items;
}
Missing Index
@Query("SELECT u FROM User u WHERE u.email = :email")
Optional<User> findByEmail(@Param("email") String email);
@Entity
@Table(name = "users", indexes = {
@Index(name = "idx_user_email", columnList = "email", unique = true),
@Index(name = "idx_user_status_created", columnList = "status, created_at")
})
public class User {
@Column(nullable = false, unique = true)
private String email;
}
toString with Lazy Fields
@Entity
public class Order {
@OneToMany(mappedBy = "order")
private List<OrderItem> items;
@Override
public String toString() {
return "Order{id=" + id + ", items=" + items + "}";
}
}
@Override
public String toString() {
return "Order{id=%d, status=%s, total=%s}".formatted(id, status, total);
}
Performance Checklist