| name | jpa-patterns |
| description | Use when working with JPA/Hibernate entities, repositories, or database operations to avoid N+1 queries, manage lazy loading, transactions, and entity relationships. |
JPA Patterns for Spring Boot
Entity Relationships
One-to-Many / Many-to-One
@Entity
@Table(name = "departments")
public class Department {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String name;
@OneToMany(mappedBy = "department", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Employee> employees = new ArrayList<>();
public Department() {}
public Department(String name) {
this.name = name;
}
public Long getId() { return id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public List<Employee> getEmployees() { return employees; }
public void addEmployee(Employee employee) {
employees.add(employee);
employee.setDepartment(this);
}
public void removeEmployee(Employee employee) {
employees.remove(employee);
employee.setDepartment(null);
}
}
@Entity
@Table(name = "employees")
public class Employee {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false, unique = true)
private String email;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "department_id", nullable = false)
private Department department;
public Employee() {}
public Employee(String name, String email) {
this.name = name;
this.email = email;
}
public Long getId() { return id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public Department getDepartment() { return department; }
public void setDepartment(Department department) { this.department = department; }
}
Many-to-Many
@Entity
@Table(name = "students")
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinTable(
name = "student_courses",
joinColumns = @JoinColumn(name = "student_id"),
inverseJoinColumns = @JoinColumn(name = "course_id")
)
private Set<Course> courses = new HashSet<>();
public Student() {}
public Long getId() { return id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public Set<Course> getCourses() { return courses; }
public void enrollIn(Course course) {
courses.add(course);
course.getStudents().add(this);
}
public void dropCourse(Course course) {
courses.remove(course);
course.getStudents().remove(this);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Student other)) return false;
return id != null && id.equals(other.getId());
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}
@Entity
@Table(name = "courses")
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToMany(mappedBy = "courses")
private Set<Student> students = new HashSet<>();
public Course() {}
public Long getId() { return id; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public Set<Student> getStudents() { return students; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Course other)) return false;
return id != null && id.equals(other.getId());
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}
N+1 Query Problem
The Problem
List<Order> orders = orderRepository.findAll();
for (Order order : orders) {
System.out.println("Items: " + order.getItems().size());
}
Solutions
1. JOIN FETCH in JPQL
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.items WHERE o.status = :status")
List<Order> findByStatusWithItems(@Param("status") OrderStatus status);
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.items JOIN FETCH o.customer")
List<Order> findAllWithItemsAndCustomer();
}
2. @EntityGraph
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = {"items", "customer"})
List<Order> findByStatus(OrderStatus status);
@EntityGraph(attributePaths = {"items"})
Optional<Order> findById(Long id);
}
3. @BatchSize for Lazy Collections
@Entity
public class Order {
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
@BatchSize(size = 20)
private List<OrderItem> items = new ArrayList<>();
}
4. Projections for Read-Only Views
public interface OrderSummary {
Long getId();
String getCustomerName();
BigDecimal getTotalAmount();
OrderStatus getStatus();
LocalDateTime getCreatedAt();
}
public interface OrderRepository extends JpaRepository<Order, Long> {
List<OrderSummary> findSummaryByStatus(OrderStatus status);
@Query("""
SELECT o.id as id, c.name as customerName,
o.totalAmount as totalAmount, o.status as status,
o.createdAt as createdAt
FROM Order o JOIN o.customer c
WHERE o.status = :status
""")
List<OrderSummary> findOrderSummaries(@Param("status") OrderStatus status);
}
Lazy Loading Best Practices
Always Use FetchType.LAZY
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items;
Avoid LazyInitializationException
@GetMapping("/{id}")
public OrderResponse getOrder(@PathVariable Long id) {
Order order = orderRepository.findById(id).orElseThrow();
return new OrderResponse(order.getId(), order.getItems().size());
}
@Service
public class OrderService {
@Transactional(readOnly = true)
public OrderResponse getOrder(Long id) {
Order order = orderRepository.findByIdWithItems(id)
.orElseThrow(() -> new ResourceNotFoundException("Order", id));
return orderMapper.toResponse(order);
}
}
Transaction Management
Read-only transactions
@Service
public class ReportService {
private final OrderRepository orderRepository;
public ReportService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Transactional(readOnly = true)
public SalesReport generateMonthlySalesReport(YearMonth month) {
LocalDate start = month.atDay(1);
LocalDate end = month.atEndOfMonth();
List<Order> orders = orderRepository.findCompletedBetween(start, end);
return buildReport(orders);
}
}
Transaction propagation
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentService paymentService;
private final AuditService auditService;
public OrderService(OrderRepository orderRepository,
PaymentService paymentService,
AuditService auditService) {
this.orderRepository = orderRepository;
this.paymentService = paymentService;
this.auditService = auditService;
}
@Transactional
public OrderResponse processOrder(OrderRequest request) {
Order order = createOrder(request);
paymentService.processPayment(order);
auditService.logOrderCreated(order);
return toResponse(order);
}
}
@Service
public class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logOrderCreated(Order order) {
auditRepository.save(new AuditEntry("ORDER_CREATED", order.getId()));
}
}
Rollback rules
@Transactional(rollbackFor = Exception.class)
public void riskyOperation() {
}
@Transactional(noRollbackFor = BusinessWarningException.class)
public void operationWithWarnings() {
}
Batch Operations
Batch Inserts
@Configuration
public class JpaConfig {
}
@Service
public class DataImportService {
private final EntityManager entityManager;
public DataImportService(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional
public void importProducts(List<ProductImportRow> rows) {
int batchSize = 50;
for (int i = 0; i < rows.size(); i++) {
Product product = mapToEntity(rows.get(i));
entityManager.persist(product);
if (i > 0 && i % batchSize == 0) {
entityManager.flush();
entityManager.clear();
}
}
entityManager.flush();
entityManager.clear();
}
}
Bulk Update/Delete with JPQL
public interface OrderRepository extends JpaRepository<Order, Long> {
@Modifying
@Query("UPDATE Order o SET o.status = :newStatus WHERE o.status = :oldStatus AND o.createdAt < :cutoff")
int updateExpiredOrders(@Param("oldStatus") OrderStatus oldStatus,
@Param("newStatus") OrderStatus newStatus,
@Param("cutoff") LocalDateTime cutoff);
@Modifying
@Query("DELETE FROM Order o WHERE o.status = 'CANCELLED' AND o.updatedAt < :cutoff")
int deleteCancelledOrdersBefore(@Param("cutoff") LocalDateTime cutoff);
}
Second-Level Cache
@Entity
@Table(name = "categories")
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String name;
@OneToMany(mappedBy = "category")
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
private List<Product> products = new ArrayList<>();
public Category() {}
public Long getId() { return id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public List<Product> getProducts() { return products; }
}
JPA Checklist