JPA and Advanced Associations: Complete Class Guide
This guide provides a complete class on key JPA concepts, focusing on associations, fetching optimization, and projection techniques, including basic examples, scenarios, and common pitfalls.
I. Introduction to JPA and Association Types
JPA (Java Persistence API), commonly implemented with Hibernate, is a fundamental tool for object-relational mapping (ORM) in Java. It allows managing Java object persistence in relational databases transparently.
What are Associations?
Associations in JPA define how entities are related to each other in the domain model. They represent real-world relationships between business objects.
| Association Type | Description | Real Example |
|---|---|---|
@OneToOne | One instance relates to exactly one | User → Profile |
@OneToMany | One instance relates to many | Order → Order Lines |
@ManyToOne | Many instances relate to one | Employees → Department |
@ManyToMany | Many instances relate to many | Students ↔ Courses |
Fundamental Concepts
Before diving deeper, it's important to understand these concepts:
- Owner Side: The side that manages the foreign key (FK) in the database
- Inverse Side: The side that uses
mappedByand doesn't manage the FK - FetchType: Defines when data is loaded (
LAZYvsEAGER) - CascadeType: Defines which operations propagate to related entities
II. One-to-One Relationships (@OneToOne)
The @OneToOne relationship is used when one instance of Entity A is associated with exactly one instance of Entity B.
Use Case Scenarios
- Data separation: Rarely accessed fields in separate table
- Scalability: Main entity with high write operations
- Security: Sensitive data in separate table with different permissions
Default Behavior
⚠️ By default,
@OneToOneusesFetchType.EAGER, which can cause performance issues.
Basic Example: Unidirectional
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@JoinColumn(name = "profile_id", referencedColumnName = "id")
private UserProfile profile;
// Getters and setters
}
@Entity
public class UserProfile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String bio;
private String avatarUrl;
// Getters and setters
}Advanced Example: Bidirectional with @MapsId
The @MapsId technique is the best practice for bidirectional @OneToOne because:
- Shares the primary key between both tables
- Enables real lazy loading on the inverse side
- Reduces storage (no additional FK)
@Entity
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
@OneToOne(mappedBy = "customer", cascade = CascadeType.ALL,
fetch = FetchType.LAZY, orphanRemoval = true)
private CustomerDetails details;
// Utility method to maintain synchronization
public void setDetails(CustomerDetails details) {
if (details == null) {
if (this.details != null) {
this.details.setCustomer(null);
}
} else {
details.setCustomer(this);
}
this.details = details;
}
}
@Entity
public class CustomerDetails {
@Id
private Long id; // Same PK as Customer
private String address;
private String phone;
private LocalDate birthDate;
@OneToOne(fetch = FetchType.LAZY)
@MapsId // Maps Customer's PK as FK and PK
@JoinColumn(name = "customer_id")
private Customer customer;
// Getters and setters
}Generated SQL
CREATE TABLE customer (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255),
email VARCHAR(255)
);
CREATE TABLE customer_details (
customer_id BIGINT PRIMARY KEY, -- PK and FK at the same time
address VARCHAR(255),
phone VARCHAR(255),
birth_date DATE,
FOREIGN KEY (customer_id) REFERENCES customer(id)
);Common Errors in @OneToOne
| Error | Cause | Solution |
|---|---|---|
| N+1 with EAGER | FetchType.EAGER by default | Use FetchType.LAZY |
| Lazy doesn't work on inverse side | Hibernate can't create proxy | Use @MapsId or bytecode enhancement |
| Orphan data | Details not deleted when parent deleted | Use orphanRemoval = true |
III. One-to-Many (@OneToMany) and Many-to-One (@ManyToOne) Relationships
These associations are inherently dual: if an entity has @ManyToOne, the relationship viewed from the other side is @OneToMany.
A. @ManyToOne - The Foreign Key Side
This association is defined on the side of the entity that contains the foreign key (FK). Many child entities relate to one parent entity.
Default Behavior
⚠️ By default,
@ManyToOneusesFetchType.EAGER. Always change it toLAZY.
Basic Example
@Entity
public class Employee {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY) // Always LAZY!
@JoinColumn(name = "department_id", nullable = false)
private Department department;
// Getters and setters
}B. @OneToMany - The Collection Side
This association is defined on the side of the entity that owns the collection of related entities.
Default Behavior
✅ By default,
@OneToManyusesFetchType.LAZY, which is correct.
Basic Example
@Entity
public class Department {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "department", cascade = CascadeType.ALL,
orphanRemoval = true)
private List<Employee> employees = new ArrayList<>();
// Getters and setters
}C. Bidirectionality and Ownership
When implementing a bidirectional association, JPA requires that one side be the owner and the other be the inverse.
Golden Rule
🔑 In a bidirectional
@OneToMany/@ManyToOneassociation, the "Many" side (@ManyToOne) MUST ALWAYS be the owner.
Complete Example: Order and OrderLine
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private LocalDateTime orderDate;
private String status;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL,
orphanRemoval = true)
private List<OrderLine> lines = new ArrayList<>();
// ✅ Utility methods to maintain bidirectional synchronization
public void addLine(OrderLine line) {
lines.add(line);
line.setOrder(this);
}
public void removeLine(OrderLine line) {
lines.remove(line);
line.setOrder(null);
}
}
@Entity
public class OrderLine {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String productName;
private Integer quantity;
private BigDecimal price;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id", nullable = false)
private Order order; // This side manages the FK
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof OrderLine)) return false;
OrderLine that = (OrderLine) o;
return id != null && id.equals(that.getId());
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}Generated SQL
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_date DATETIME,
status VARCHAR(50)
);
CREATE TABLE order_line (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
product_name VARCHAR(255),
quantity INT,
price DECIMAL(10,2),
order_id BIGINT NOT NULL, -- FK managed by @ManyToOne
FOREIGN KEY (order_id) REFERENCES orders(id)
);Performance Pitfall: Why Must the Many Side Be Owner?
| Scenario | SQL Queries | Explanation |
|---|---|---|
@ManyToOne as owner | N + 1 | 1 INSERT per OrderLine |
@OneToMany as owner | 2N + 1 | 1 INSERT + 1 FK UPDATE per OrderLine |
// ❌ BAD: @OneToMany as owner (without mappedBy)
@OneToMany
@JoinColumn(name = "order_id") // This makes Order the owner
private List<OrderLine> lines;
// Result: INSERT order_line + UPDATE order_line SET order_id = ?// ✅ GOOD: @ManyToOne as owner
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;
// Result: INSERT order_line (with order_id included)Common Errors in @OneToMany / @ManyToOne
| Error | Cause | Solution |
|---|---|---|
| Context inconsistency | Not synchronizing both sides | Use utility methods (addLine, removeLine) |
LazyInitializationException | Accessing collection outside transaction | Use JOIN FETCH or @Transactional |
| Duplicates in collection | Not implementing equals/hashCode | Implement based on ID or business key |
| Degraded performance | @ManyToOne with EAGER | Always use FetchType.LAZY |
IV. Many-to-Many Relationships (@ManyToMany)
@ManyToMany associations are very common but present several pitfalls that must be carefully avoided.
Basic Example: Bidirectional
@Entity
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@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<>(); // ✅ Use Set, NOT List
// Utility methods
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;
private String title;
@ManyToMany(mappedBy = "courses")
private Set<Student> students = new HashSet<>();
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Course)) return false;
Course course = (Course) o;
return id != null && id.equals(course.getId());
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}Generated SQL
CREATE TABLE student (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255)
);
CREATE TABLE course (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(255)
);
CREATE TABLE student_course (
student_id BIGINT NOT NULL,
course_id BIGINT NOT NULL,
PRIMARY KEY (student_id, course_id),
FOREIGN KEY (student_id) REFERENCES student(id),
FOREIGN KEY (course_id) REFERENCES course(id)
);Best Practices and Common Errors
| # | Practice | Implementation | Potential Error |
|---|---|---|---|
| 1 | Use Set | Set<Course> courses = new HashSet<>() | ❌ List causes mass deletion and reinsertion |
| 2 | Utility methods | addCourse(), removeCourse() | ❌ Inconsistent context if both sides not synchronized |
| 3 | FetchType.LAZY | Default value, don't change | ❌ EAGER causes performance issues |
| 4 | Avoid dangerous CascadeType | Only PERSIST and MERGE | ❌ REMOVE can delete shared data |
| 5 | Implement equals/hashCode | Based on ID or business key | ❌ Duplicates and erratic behavior |
Why NOT Use List in @ManyToMany?
// ❌ BAD: Using List
@ManyToMany
private List<Course> courses = new ArrayList<>();
// When removing ONE course, Hibernate executes:
// DELETE FROM student_course WHERE student_id = ? -- ALL!
// INSERT INTO student_course VALUES (?, ?) -- Reinserts remaining
// INSERT INTO student_course VALUES (?, ?)
// ...// ✅ GOOD: Using Set
@ManyToMany
private Set<Course> courses = new HashSet<>();
// When removing ONE course, Hibernate executes:
// DELETE FROM student_course WHERE student_id = ? AND course_id = ? -- Only oneAdvanced Pattern: Link Entity
When you need additional attributes in the relationship (enrollment date, grade, etc.), use a link entity:
@Entity
public class Enrollment {
@EmbeddedId
private EnrollmentId id;
@ManyToOne(fetch = FetchType.LAZY)
@MapsId("studentId")
@JoinColumn(name = "student_id")
private Student student;
@ManyToOne(fetch = FetchType.LAZY)
@MapsId("courseId")
@JoinColumn(name = "course_id")
private Course course;
private LocalDate enrollmentDate;
private Double grade;
// Constructor, getters, setters
}
@Embeddable
public class EnrollmentId implements Serializable {
private Long studentId;
private Long courseId;
// equals, hashCode
}V. Efficient Data Loading: The N+1 Problem and Fetching Strategies
A. The N+1 SELECT Problem
The N+1 problem occurs when:
- 1 query is executed to get the main entities
- N additional queries to load each entity's associations
Problem Example
// Code that causes N+1
List<Order> orders = orderRepository.findAll(); // 1 query
for (Order order : orders) {
// Each access to lines triggers an additional query
System.out.println(order.getLines().size()); // N queries
}-- Query 1: Get orders
SELECT * FROM orders;
-- Queries N: One per order
SELECT * FROM order_line WHERE order_id = 1;
SELECT * FROM order_line WHERE order_id = 2;
SELECT * FROM order_line WHERE order_id = 3;
-- ... N timesB. Solution 1: JOIN FETCH (JPQL)
The JOIN FETCH clause tells Hibernate which associations to initialize immediately.
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.lines WHERE o.status = :status")
List<Order> findByStatusWithLines(@Param("status") String status);
@Query("SELECT DISTINCT o FROM Order o " +
"JOIN FETCH o.lines l " +
"JOIN FETCH o.customer " +
"WHERE o.orderDate >= :date")
List<Order> findRecentOrdersWithDetails(@Param("date") LocalDateTime date);
}-- Single query with JOIN
SELECT DISTINCT o.*, l.*, c.*
FROM orders o
INNER JOIN order_line l ON o.id = l.order_id
INNER JOIN customer c ON o.customer_id = c.id
WHERE o.order_date >= ?C. Solution 2: Entity Graphs
Entity Graphs allow defining which associations to load declaratively.
Definition with @NamedEntityGraph
@Entity
@NamedEntityGraph(
name = "Order.withLinesAndCustomer",
attributeNodes = {
@NamedAttributeNode("lines"),
@NamedAttributeNode("customer")
}
)
public class Order {
// ...
}Usage in Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(value = "Order.withLinesAndCustomer")
List<Order> findByStatus(String status);
// Or define inline
@EntityGraph(attributePaths = {"lines", "customer"})
Optional<Order> findById(Long id);
}Programmatic Usage
@Service
@Transactional(readOnly = true)
public class OrderService {
@PersistenceContext
private EntityManager em;
public List<Order> findOrdersWithDetails() {
EntityGraph<Order> graph = em.createEntityGraph(Order.class);
graph.addAttributeNodes("lines");
graph.addSubgraph("customer").addAttributeNodes("details");
return em.createQuery("SELECT o FROM Order o", Order.class)
.setHint("jakarta.persistence.loadgraph", graph)
.getResultList();
}
}D. Solution 3: Batch Fetching
Batch fetching reduces N+1 to N/batchSize + 1 queries.
@Entity
public class Order {
@OneToMany(mappedBy = "order")
@BatchSize(size = 25) // Loads up to 25 collections per query
private List<OrderLine> lines;
}-- Instead of N individual queries:
SELECT * FROM order_line WHERE order_id IN (1, 2, 3, ..., 25);
SELECT * FROM order_line WHERE order_id IN (26, 27, 28, ..., 50);
-- etc.Global Configuration
# application.properties
spring.jpa.properties.hibernate.default_batch_fetch_size=25E. Pitfall: MultipleBagFetchException
❌ Error:
cannot simultaneously fetch multiple bags
Occurs when trying to JOIN FETCH multiple List collections (bags).
// ❌ This fails
@Query("SELECT o FROM Order o JOIN FETCH o.lines JOIN FETCH o.payments")
List<Order> findWithLinesAndPayments();Solutions
- Convert
ListtoSet:
@OneToMany(mappedBy = "order")
private Set<OrderLine> lines = new HashSet<>();
@OneToMany(mappedBy = "order")
private Set<Payment> payments = new HashSet<>();- Multiple separate queries:
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.lines")
List<Order> findWithLines();
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.payments WHERE o IN :orders")
List<Order> fetchPayments(@Param("orders") List<Order> orders);F. When NOT to Use JOIN FETCH
⚠️ Sometimes, N+1 is better than a massive JOIN.
If a query with multiple JOINs generates a huge Cartesian product:
54 orders × 54 lines × 54 payments = 157,464 rowsIt's more efficient to make 3 simple queries:
54 + 54 + 54 = 162 total rowsRule of thumb: Use JOIN FETCH for 1-2 associations. For more, consider batch fetching or separate queries.
VI. Projections: Optimizing Data Retrieval
Projections allow retrieving only necessary data, improving performance and reducing memory usage.
A. Interface-based Projections
The simplest form. Spring Data JPA creates a proxy automatically.
// Define the projection
public interface OrderSummary {
Long getId();
LocalDateTime getOrderDate();
String getStatus();
// Computed property with SpEL
@Value("#{target.lines.size()}")
int getLineCount();
}
// Use in repository
public interface OrderRepository extends JpaRepository<Order, Long> {
List<OrderSummary> findByStatus(String status);
@Query("SELECT o.id as id, o.orderDate as orderDate, o.status as status " +
"FROM Order o WHERE o.customer.id = :customerId")
List<OrderSummary> findSummariesByCustomer(@Param("customerId") Long customerId);
}Limitations
- Cannot customize
equals()/hashCode() - Less efficient than DTOs for complex queries
B. Class-based Projections (DTOs)
More control and better performance for complex queries.
Using Records (Java 16+)
public record OrderDTO(
Long id,
LocalDateTime orderDate,
String status,
String customerName,
BigDecimal totalAmount
) {}
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("""
SELECT new com.example.dto.OrderDTO(
o.id,
o.orderDate,
o.status,
c.name,
SUM(l.price * l.quantity)
)
FROM Order o
JOIN o.customer c
JOIN o.lines l
WHERE o.status = :status
GROUP BY o.id, o.orderDate, o.status, c.name
""")
List<OrderDTO> findOrderSummaries(@Param("status") String status);
}Using Traditional POJO
public class OrderDetailDTO {
private Long orderId;
private String customerName;
private List<LineDTO> lines;
public OrderDetailDTO(Long orderId, String customerName) {
this.orderId = orderId;
this.customerName = customerName;
this.lines = new ArrayList<>();
}
// Getters, setters
}
public class LineDTO {
private String productName;
private Integer quantity;
private BigDecimal price;
public LineDTO(String productName, Integer quantity, BigDecimal price) {
this.productName = productName;
this.quantity = quantity;
this.price = price;
}
}C. Dynamic Projections
Allow choosing the projection type at runtime.
public interface OrderRepository extends JpaRepository<Order, Long> {
// Generic method that accepts any projection type
<T> List<T> findByStatus(String status, Class<T> type);
<T> Optional<T> findById(Long id, Class<T> type);
}
// Usage
List<OrderSummary> summaries = orderRepository.findByStatus("PENDING", OrderSummary.class);
List<OrderDTO> dtos = orderRepository.findByStatus("PENDING", OrderDTO.class);
List<Order> entities = orderRepository.findByStatus("PENDING", Order.class);D. Tuple Projections
For ad-hoc queries without creating DTOs.
@Query("SELECT o.id, o.status, COUNT(l) FROM Order o LEFT JOIN o.lines l GROUP BY o.id, o.status")
List<Object[]> findOrderStats();
// Or using Tuple
@Query("SELECT o.id as id, o.status as status, COUNT(l) as lineCount " +
"FROM Order o LEFT JOIN o.lines l GROUP BY o.id, o.status")
List<Tuple> findOrderStatsTuple();
// Usage
List<Tuple> stats = orderRepository.findOrderStatsTuple();
for (Tuple t : stats) {
Long id = t.get("id", Long.class);
String status = t.get("status", String.class);
Long count = t.get("lineCount", Long.class);
}Projection Comparison
| Type | Advantages | Disadvantages | Recommended Use |
|---|---|---|---|
| Interface | Simple, automatic | No equals/hashCode, reflection | Simple queries |
| DTO/Record | Full control, efficient | More code | Complex queries |
| Dynamic | Flexible | Less type-safe | APIs with multiple views |
| Tuple | No additional classes | Less readable | Ad-hoc queries |
VII. Native Queries
Native queries allow executing SQL directly, necessary when JPQL is not sufficient.
A. JPQL vs Native Queries
| Feature | JPQL | Native Query |
|---|---|---|
| Abstraction | DB independent | DB specific |
| Complexity | Limited for complex queries | Full SQL available |
| Portability | High | Low |
| DB Functions | Limited | All available |
B. Basic Syntax
public interface OrderRepository extends JpaRepository<Order, Long> {
// Simple native query
@Query(value = "SELECT * FROM orders WHERE status = ?1", nativeQuery = true)
List<Order> findByStatusNative(String status);
// With @NativeQuery (Spring Data 3.x)
@NativeQuery("SELECT * FROM orders WHERE YEAR(order_date) = :year")
List<Order> findByYear(@Param("year") int year);
// With pagination
@Query(
value = "SELECT * FROM orders WHERE status = :status",
countQuery = "SELECT COUNT(*) FROM orders WHERE status = :status",
nativeQuery = true
)
Page<Order> findByStatusPaged(@Param("status") String status, Pageable pageable);
}C. Projections with Native Queries
// Projection to interface
public interface OrderNativeSummary {
Long getId();
String getStatus();
Integer getLineCount();
}
@Query(value = """
SELECT o.id, o.status, COUNT(l.id) as lineCount
FROM orders o
LEFT JOIN order_line l ON o.id = l.order_id
GROUP BY o.id, o.status
""", nativeQuery = true)
List<OrderNativeSummary> findNativeSummaries();
// Projection to Map
@NativeQuery("SELECT * FROM orders WHERE id = :id")
Map<String, Object> findRawById(@Param("id") Long id);D. Hierarchical DTOs with Native Queries
For complex structures, use a Custom Repository with ResultTransformer.
// Hierarchical DTO
public class OrderWithLinesDTO {
private Long id;
private String status;
private List<LineDTO> lines = new ArrayList<>();
}
// Custom Repository Implementation
@Repository
public class OrderRepositoryCustomImpl implements OrderRepositoryCustom {
@PersistenceContext
private EntityManager em;
@Override
@SuppressWarnings("unchecked")
public List<OrderWithLinesDTO> findOrdersWithLinesNative() {
String sql = """
SELECT o.id as orderId, o.status,
l.id as lineId, l.product_name, l.quantity, l.price
FROM orders o
LEFT JOIN order_line l ON o.id = l.order_id
ORDER BY o.id
""";
List<Object[]> results = em.createNativeQuery(sql).getResultList();
Map<Long, OrderWithLinesDTO> orderMap = new LinkedHashMap<>();
for (Object[] row : results) {
Long orderId = ((Number) row[0]).longValue();
OrderWithLinesDTO order = orderMap.computeIfAbsent(orderId, id -> {
OrderWithLinesDTO dto = new OrderWithLinesDTO();
dto.setId(id);
dto.setStatus((String) row[1]);
return dto;
});
if (row[2] != null) { // If there's a line
LineDTO line = new LineDTO(
(String) row[3],
((Number) row[4]).intValue(),
(BigDecimal) row[5]
);
order.getLines().add(line);
}
}
return new ArrayList<>(orderMap.values());
}
}E. When to Use Native Queries
| Scenario | Recommendation |
|---|---|
| DB-specific functions (JSONB, arrays) | ✅ Native |
| Window functions (ROW_NUMBER, RANK) | ✅ Native |
| CTEs (WITH clause) | ✅ Native |
| Simple CRUD queries | ❌ Use JPQL or Query Methods |
| Portability between DBs | ❌ Use JPQL |
VIII. Custom Repositories
When standard options aren't sufficient, implement a custom repository.
Structure
// 1. Interface with custom methods
public interface OrderRepositoryCustom {
List<Order> findOrdersDynamic(OrderSearchCriteria criteria);
List<OrderWithLinesDTO> findOrdersWithLinesNative();
}
// 2. Implementation (name MUST end in "Impl")
@Repository
public class OrderRepositoryCustomImpl implements OrderRepositoryCustom {
@PersistenceContext
private EntityManager em;
@Override
public List<Order> findOrdersDynamic(OrderSearchCriteria criteria) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Order> query = cb.createQuery(Order.class);
Root<Order> order = query.from(Order.class);
List<Predicate> predicates = new ArrayList<>();
if (criteria.getStatus() != null) {
predicates.add(cb.equal(order.get("status"), criteria.getStatus()));
}
if (criteria.getFromDate() != null) {
predicates.add(cb.greaterThanOrEqualTo(
order.get("orderDate"), criteria.getFromDate()));
}
if (criteria.getCustomerId() != null) {
predicates.add(cb.equal(
order.get("customer").get("id"), criteria.getCustomerId()));
}
// Dynamic JOIN FETCH
if (criteria.isFetchLines()) {
order.fetch("lines", JoinType.LEFT);
}
query.where(predicates.toArray(new Predicate[0]));
query.distinct(true);
return em.createQuery(query).getResultList();
}
}
// 3. Main repository extends both
public interface OrderRepository extends
JpaRepository<Order, Long>,
OrderRepositoryCustom {
// Standard query methods here
}IX. Best Practices Summary
Associations
| Practice | Description |
|---|---|
✅ Always FetchType.LAZY | Except very specific cases |
✅ @ManyToOne as owner | In bidirectional relationships |
✅ Set for @ManyToMany | Avoids mass deletion |
| ✅ Utility methods | Maintain bidirectional synchronization |
✅ @MapsId for @OneToOne | Better performance and lazy loading |
❌ CascadeType.REMOVE in @ManyToMany | Can delete shared data |
❌ List in @ManyToMany | Causes mass deletion and reinsertion |
Fetching
| Practice | Description |
|---|---|
✅ JOIN FETCH for 1-2 associations | Most common N+1 solution |
| ✅ Entity Graphs for declarative cases | More readable than JPQL |
| ✅ Batch fetching for multiple collections | Avoids MultipleBagFetchException |
❌ Multiple JOIN FETCH with List | Causes MultipleBagFetchException |
| ❌ Massive JOINs with Cartesian product | Sometimes N+1 is better |
Projections
| Practice | Description |
|---|---|
| ✅ Projections for reading | Better performance than full entities |
| ✅ Records for DTOs | Immutable and concise |
| ✅ Interface projections for simple cases | Less code |
| ✅ Custom repository for complex cases | Full control |
X. Code Review Checklist
Use this list when reviewing JPA code:
- Do all
@ManyToOneand@OneToOneassociations haveFetchType.LAZY? - Do
@ManyToManycollections useSetinstead ofList? - Is the
@ManyToOneside the owner in bidirectional relationships? - Are there utility methods to synchronize bidirectional associations?
- Are
equals()andhashCode()implemented correctly in entities? - Do queries loading collections use
JOIN FETCHor Entity Graphs? - Are projections used for read-only queries?
- Is
CascadeType.REMOVEavoided in@ManyToMany? - Do queries with multiple JOINs not generate huge Cartesian products?
This guide is based on official Hibernate ORM documentation, Spring Data JPA, and best practices established by experts like Vlad Mihalcea and Thorben Janssen. For deeper understanding, consult the official documentation and mentioned resources.