Zademy

JPA and Advanced Associations: Complete Class Guide

JPA; Hibernate; Spring Data; ORM; Performance
2989 words

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 TypeDescriptionReal Example
@OneToOneOne instance relates to exactly oneUser → Profile
@OneToManyOne instance relates to manyOrder → Order Lines
@ManyToOneMany instances relate to oneEmployees → Department
@ManyToManyMany instances relate to manyStudents ↔ 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 mappedBy and doesn't manage the FK
  • FetchType: Defines when data is loaded (LAZY vs EAGER)
  • 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, @OneToOne uses FetchType.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

ErrorCauseSolution
N+1 with EAGERFetchType.EAGER by defaultUse FetchType.LAZY
Lazy doesn't work on inverse sideHibernate can't create proxyUse @MapsId or bytecode enhancement
Orphan dataDetails not deleted when parent deletedUse 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, @ManyToOne uses FetchType.EAGER. Always change it to LAZY.

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, @OneToMany uses FetchType.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 / @ManyToOne association, 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?

ScenarioSQL QueriesExplanation
@ManyToOne as ownerN + 11 INSERT per OrderLine
@OneToMany as owner2N + 11 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

ErrorCauseSolution
Context inconsistencyNot synchronizing both sidesUse utility methods (addLine, removeLine)
LazyInitializationExceptionAccessing collection outside transactionUse JOIN FETCH or @Transactional
Duplicates in collectionNot implementing equals/hashCodeImplement based on ID or business key
Degraded performance@ManyToOne with EAGERAlways 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

#PracticeImplementationPotential Error
1Use SetSet<Course> courses = new HashSet<>()List causes mass deletion and reinsertion
2Utility methodsaddCourse(), removeCourse()❌ Inconsistent context if both sides not synchronized
3FetchType.LAZYDefault value, don't changeEAGER causes performance issues
4Avoid dangerous CascadeTypeOnly PERSIST and MERGEREMOVE can delete shared data
5Implement equals/hashCodeBased 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 one

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 times

B. 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=25

E. 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

  1. Convert List to Set:
@OneToMany(mappedBy = "order")
private Set<OrderLine> lines = new HashSet<>();

@OneToMany(mappedBy = "order")
private Set<Payment> payments = new HashSet<>();
  1. 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 rows

It's more efficient to make 3 simple queries:

54 + 54 + 54 = 162 total rows

Rule 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

TypeAdvantagesDisadvantagesRecommended Use
InterfaceSimple, automaticNo equals/hashCode, reflectionSimple queries
DTO/RecordFull control, efficientMore codeComplex queries
DynamicFlexibleLess type-safeAPIs with multiple views
TupleNo additional classesLess readableAd-hoc queries

VII. Native Queries

Native queries allow executing SQL directly, necessary when JPQL is not sufficient.

A. JPQL vs Native Queries

FeatureJPQLNative Query
AbstractionDB independentDB specific
ComplexityLimited for complex queriesFull SQL available
PortabilityHighLow
DB FunctionsLimitedAll 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

ScenarioRecommendation
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

PracticeDescription
✅ Always FetchType.LAZYExcept very specific cases
@ManyToOne as ownerIn bidirectional relationships
Set for @ManyToManyAvoids mass deletion
✅ Utility methodsMaintain bidirectional synchronization
@MapsId for @OneToOneBetter performance and lazy loading
CascadeType.REMOVE in @ManyToManyCan delete shared data
List in @ManyToManyCauses mass deletion and reinsertion

Fetching

PracticeDescription
JOIN FETCH for 1-2 associationsMost common N+1 solution
✅ Entity Graphs for declarative casesMore readable than JPQL
✅ Batch fetching for multiple collectionsAvoids MultipleBagFetchException
❌ Multiple JOIN FETCH with ListCauses MultipleBagFetchException
❌ Massive JOINs with Cartesian productSometimes N+1 is better

Projections

PracticeDescription
✅ Projections for readingBetter performance than full entities
✅ Records for DTOsImmutable and concise
✅ Interface projections for simple casesLess code
✅ Custom repository for complex casesFull control

X. Code Review Checklist

Use this list when reviewing JPA code:

  • Do all @ManyToOne and @OneToOne associations have FetchType.LAZY?
  • Do @ManyToMany collections use Set instead of List?
  • Is the @ManyToOne side the owner in bidirectional relationships?
  • Are there utility methods to synchronize bidirectional associations?
  • Are equals() and hashCode() implemented correctly in entities?
  • Do queries loading collections use JOIN FETCH or Entity Graphs?
  • Are projections used for read-only queries?
  • Is CascadeType.REMOVE avoided 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.