🔗

Relații JPA — Ghid Complet

OneToMany, ManyToMany, ManyToOne, OneToOne cu best practices și anti-patterns

📖 Introducere

Relațiile între entități sunt fundamentul oricărei aplicații bazate pe JPA/Hibernate. Acest ghid acoperă toate tipurile de relații, cu accent pe best practices și greșeli comune de evitat.

Tipuri de relații
Relație Exemplu real FK în
@OneToOne User → UserProfile Oricare parte
@OneToMany Department → Employees Partea "Many"
@ManyToOne Employee → Department Partea "Many"
@ManyToMany Student ↔ Course Tabel join

Regula de aur

Întotdeauna gândește relația din perspectiva bazei de date. Foreign key-ul este mereu în tabela "many". Aceasta determină care parte "deține" relația.

1️⃣ @OneToMany / @ManyToOne

Cea mai comună relație. Un părinte are mai mulți copii, fiecare copil aparține unui singur părinte.

Department (1) ←→ (N) Employee
Department
id, name
1 ───── has many ───── N
Employee
id, name, department_id (FK)

🏆 Best Practice: Relație bidirecțională cu mappedBy

@Entity
public class Department {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    
    @OneToMany(
        mappedBy = "department",      // câmpul din Employee care deține FK
        cascade = CascadeType.ALL,     // operațiile se propagă la copii
        orphanRemoval = true          // șterge copiii orfani
    )
    private List<Employee> employees = new ArrayList<>();
    
    // ═══ Metode helper pentru sincronizare bidirecțională ═══
    public void addEmployee(Employee employee) {
        employees.add(employee);
        employee.setDepartment(this);
    }
    
    public void removeEmployee(Employee employee) {
        employees.remove(employee);
        employee.setDepartment(null);
    }
}
@Entity
public class Employee {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    
    @ManyToOne(fetch = FetchType.LAZY)  // LAZY este crucial!
    @JoinColumn(name = "department_id") // numele coloanei FK
    private Department department;
    
    // equals() și hashCode() bazate pe ID sau business key
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Employee other)) return false;
        return id != null && id.equals(other.getId());
    }
    
    @Override
    public int hashCode() {
        return getClass().hashCode();
    }
}
✅ Best Practices
  • mappedBy pe partea @OneToMany — evită tabel join inutil
  • FetchType.LAZY pe @ManyToOne — default e EAGER, schimbă-l!
  • Metode helper pentru add/remove — păstrează sincronizarea
  • Inițializează colecțiile cu new ArrayList<>()
  • equals/hashCode corecte — esențiale pentru colecții
❌ Anti-patterns
  • @OneToMany fără mappedBy — creează tabel join inutil
  • EAGER fetch pe colecții — probleme de performanță masive
  • Setare directă fără helper — desincronizare bidirecțională
  • CascadeType.ALL peste tot — poate șterge date accidental

Greșeala clasică: @OneToMany fără mappedBy

❌ Greșit
@OneToMany
private List<Employee> employees;

// Creează tabel join:
// department_employees(
//   department_id,
//   employee_id
// )
✅ Corect
@OneToMany(mappedBy = "department")
private List<Employee> employees;

// FK rămâne în employees:
// employees(
//   ..., department_id
// )

🔙 @ManyToOne în detaliu

@ManyToOne este partea care deține relația (owning side). Foreign key-ul este aici.

Configurări importante

@ManyToOne(
    fetch = FetchType.LAZY,           // ⚠️ Default e EAGER!
    optional = false                 // NOT NULL constraint
)
@JoinColumn(
    name = "department_id",         // nume coloană FK
    nullable = false,                // NOT NULL în DB
    foreignKey = @ForeignKey(name = "fk_employee_department")
)
private Department department;

Când folosești doar @ManyToOne (unidirecțional)

Dacă nu ai nevoie să navighezi de la Department la Employees, relația unidirecțională e mai simplă:

@Entity
public class Employee {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "department_id")
    private Department department;
}

@Entity
public class Department {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    
    // Fără listă de employees!
}
💡 Când alegi unidirecțional

Folosește relație unidirecțională @ManyToOne când:

  • Nu ai nevoie să iterezi copiii din părinte
  • Colecția ar fi foarte mare (mii de elemente)
  • Vrei să păstrezi entitatea părinte simplă

Poți oricând să obții copiii cu un query: findByDepartmentId(Long id)

🔄 @ManyToMany

Relație între două entități unde fiecare poate fi asociată cu mai multe din cealaltă parte. Necesită tabel join.

Student (N) ←→ (M) Course
Student
id, name
N ───
student_course
student_id, course_id
─── M
Course
id, title

Implementare de bază

@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<>();
    
    // Helper 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")  // inverse side
    private Set<Student> students = new HashSet<>();
}
✅ Best Practices pentru @ManyToMany
  • Folosește Set, nu List — elimină duplicate și e mai eficient
  • Nu folosi CascadeType.REMOVE — poți șterge date accidental!
  • Implementează equals/hashCode corect — crucial pentru Set
  • Helper methods — sincronizează ambele părți

⭐ ManyToMany cu atribute extra — Entitate Join

Când ai nevoie de atribute pe relație (ex: data înscrierii, nota), folosește o entitate join explicită:

Student ←→ Enrollment ←→ Course
Student
1──N
Enrollment
enrolledAt, grade
N──1
Course
@Entity
@Table(name = "enrollment")
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;
    
    // Extra attributes!
    private LocalDateTime enrolledAt;
    private Integer grade;
    
    // Constructors
    public Enrollment() {}
    
    public Enrollment(Student student, Course course) {
        this.id = new EnrollmentId(student.getId(), course.getId());
        this.student = student;
        this.course = course;
        this.enrolledAt = LocalDateTime.now();
    }
}
@Embeddable
public class EnrollmentId implements Serializable {
    
    private Long studentId;
    private Long courseId;
    
    // equals() și hashCode() obligatorii!
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof EnrollmentId that)) return false;
        return Objects.equals(studentId, that.studentId) &&
               Objects.equals(courseId, that.courseId);
    }
    
    @Override
    public int hashCode() {
        return Objects.hash(studentId, courseId);
    }
}
@Entity
public class Student {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    
    @OneToMany(mappedBy = "student", cascade = CascadeType.ALL, orphanRemoval = true)
    private Set<Enrollment> enrollments = new HashSet<>();
    
    public void enrollIn(Course course) {
        Enrollment enrollment = new Enrollment(this, course);
        enrollments.add(enrollment);
        course.getEnrollments().add(enrollment);
    }
}
💡 Când folosești entitate join explicită
  • Ai atribute pe relație (data, status, scor)
  • Vrei control total asupra tabelului join
  • Ai nevoie de query-uri pe relație
  • Relația e un concept de business (Enrollment, Membership)

1️⃣↔️1️⃣ @OneToOne

Un obiect asociat cu exact un alt obiect. Cel mai adesea pentru a separa date rar accesate.

User (1) ←→ (1) UserProfile
User
id, email, password
1 ───────── 1
UserProfile
id/user_id, bio, avatar

Varianta 1: FK în entitatea părinte (recomandat)

@Entity
public class User {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String email;
    
    @OneToOne(
        cascade = CascadeType.ALL,
        fetch = FetchType.LAZY,
        orphanRemoval = true
    )
    @JoinColumn(name = "profile_id")
    private UserProfile profile;
}
@Entity
public class UserProfile {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String bio;
    private String avatarUrl;
    
    @OneToOne(mappedBy = "profile", fetch = FetchType.LAZY)
    private User user;
}

Varianta 2: Shared Primary Key (ID comun)

UserProfile folosește același ID ca User — mai eficient, fără coloană FK extra.

@Entity
public class User {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String email;
    
    @OneToOne(
        mappedBy = "user",
        cascade = CascadeType.ALL,
        fetch = FetchType.LAZY
    )
    @PrimaryKeyJoinColumn
    private UserProfile profile;
}
@Entity
public class UserProfile {
    
    @Id  // Același ID ca User, nu @GeneratedValue!
    private Long id;
    
    private String bio;
    
    @OneToOne(fetch = FetchType.LAZY)
    @MapsId  // Mapează ID-ul din User
    @JoinColumn(name = "id")
    private User user;
}
❌ Problema LAZY pe @OneToOne bidirecțional

Hibernate nu poate face LAZY pe mappedBy side-ul @OneToOne deoarece nu știe dacă relația există fără query. Soluții:

  • Folosește @MapsId (shared PK)
  • Fă relația unidirecțională
  • Activează bytecode enhancement
  • Acceptă EAGER dacă nu e critic
• • •

Fetch Types

Valori default

Relație Default Recomandat
@OneToOne EAGER ⚠️ LAZY
@ManyToOne EAGER ⚠️ LAZY
@OneToMany LAZY ✓ LAZY
@ManyToMany LAZY ✓ LAZY
✅ Regula: Totul LAZY, fetch când ai nevoie

Setează toate relațiile pe LAZY. Încarcă eager doar în query-uri specifice cu JOIN FETCH sau @EntityGraph.

Încărcare eager când ai nevoie

public interface UserRepository extends JpaRepository<User, Long> {
    
    // JOIN FETCH în JPQL
    @Query("SELECT u FROM User u JOIN FETCH u.profile WHERE u.id = :id")
    Optional<User> findByIdWithProfile(@Param("id") Long id);
    
    // Sau cu @EntityGraph
    @EntityGraph(attributePaths = {"profile", "roles"})
    Optional<User> findWithDetailsById(Long id);
    
    // EntityGraph named (definit pe entitate)
    @EntityGraph(value = "User.withOrders")
    List<User> findByStatus(Status status);
}
@Entity
@NamedEntityGraph(
    name = "User.withOrders",
    attributeNodes = {
        @NamedAttributeNode("profile"),
        @NamedAttributeNode(value = "orders", subgraph = "orders-items")
    },
    subgraphs = @NamedSubgraph(
        name = "orders-items",
        attributeNodes = @NamedAttributeNode("items")
    )
)
public class User { ... }

🌊 Cascade Types

Cascade propagă operațiile de la părinte la copii.

Type Operație Când folosești
PERSIST save() Salvează copiii odată cu părintele
MERGE merge() Update-ează copiii odată cu părintele
REMOVE delete() ⚠️ Șterge copiii — atenție!
REFRESH refresh() Reîncarcă copiii din DB
DETACH detach() Detașează copiii din persistence context
ALL toate Parent-child strâns cuplate
✅ Când folosești CascadeType.ALL
// Parent deține complet copiii
// Copiii nu există fără părinte

@OneToMany(
  mappedBy = "order",
  cascade = CascadeType.ALL,
  orphanRemoval = true
)
private List<OrderItem> items;
❌ Când NU folosești REMOVE
// Copiii pot exista independent
// Ștergerea părintelui nu trebuie 
// să șteargă copiii

@ManyToMany  // NICIODATĂ REMOVE!
private Set<Tag> tags;

@ManyToOne   // Ar șterge părintele!
private Department dept;

orphanRemoval vs CascadeType.REMOVE

// CascadeType.REMOVE
// → Șterge copiii când ștergi părintele
orderRepository.delete(order);  // → DELETE items

// orphanRemoval = true
// → Șterge copiii când îi scoți din colecție
order.getItems().remove(item);  // → DELETE item din DB
order.getItems().clear();       // → DELETE ALL items

↔️ Sincronizare Bidirecțională

În relații bidirecționale, ambele părți trebuie sincronizate manual. Hibernate nu face asta automat!

❌ Greșeală comună
// Setezi doar o parte — relația e desincronizată!
employee.setDepartment(department);
// department.getEmployees() nu conține employee!

// Sau invers:
department.getEmployees().add(employee);
// employee.getDepartment() e null!
✅ Soluția: Metode Helper
public void addEmployee(Employee employee) {
    employees.add(employee);
    employee.setDepartment(this);
}

public void removeEmployee(Employee employee) {
    employees.remove(employee);
    employee.setDepartment(null);
}

Pattern complet pentru equals/hashCode

@Entity
public class Employee {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    // ═══ equals/hashCode pentru entități ═══
    // NU include relații — doar ID sau business key
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Employee other)) return false;
        // Compară doar dacă ambele au ID (sunt persistate)
        return id != null && id.equals(other.getId());
    }
    
    @Override
    public int hashCode() {
        // Constant — nu depinde de ID care poate fi null inițial
        return getClass().hashCode();
    }
    
    // SAU cu business key (dacă ai unul unic și imutabil):
    // return Objects.hash(email);
}
💡 De ce hashCode constant?

ID-ul e null înainte de persist. Dacă hashCode depinde de ID, entitatea își schimbă hash-ul după save — și dispare din Set/Map!

💥 Problema N+1

Cea mai comună problemă de performanță în JPA. Apare când încerci să accesezi relații LAZY într-un loop.

❌ Problema
// 1 query pentru departments
List<Department> depts = departmentRepository.findAll();

// N queries pentru employees (una per department!)
for (Department d : depts) {
    System.out.println(d.getEmployees().size());  // → SELECT employees...
}

// Total: 1 + N queries! 
// Cu 100 departments = 101 queries

Soluții

1. JOIN FETCH

@Query("SELECT d FROM Department d JOIN FETCH d.employees")
List<Department> findAllWithEmployees();

// 1 singur query cu JOIN!

2. @EntityGraph

@EntityGraph(attributePaths = {"employees"})
List<Department> findAll();

3. @BatchSize (Hibernate)

@OneToMany(mappedBy = "department")
@BatchSize(size = 25)  // Încarcă 25 colecții per query
private List<Employee> employees;

// Cu 100 departments: 1 + 4 queries (în loc de 101)

4. @Fetch(FetchMode.SUBSELECT)

@OneToMany(mappedBy = "department")
@Fetch(FetchMode.SUBSELECT)
private List<Employee> employees;

// 2 queries total:
// 1. SELECT * FROM department
// 2. SELECT * FROM employee WHERE department_id IN (SELECT id FROM department)
✅ Strategia recomandată
  • Default: Totul LAZY
  • Query specific: JOIN FETCH sau @EntityGraph
  • Fallback global: @BatchSize(size = 25) pe colecții
  • Monitorizare: Activează spring.jpa.show-sql=true în dev

🎯 Tips & Best Practices

📋 Checklist pentru relații JPA
  1. @ManyToOne — setează fetch = LAZY
  2. @OneToMany — folosește mappedBy
  3. Bidirecțional — creează helper methods
  4. Colecții — inițializează cu new ArrayList<>()
  5. equals/hashCode — bazate pe ID sau business key
  6. @ManyToMany — folosește Set, nu List
  7. Cascade — nu folosi REMOVE/ALL pe @ManyToMany
  8. N+1 — folosește JOIN FETCH pentru colecții accesate

Set vs List pentru colecții

List Set
Permite duplicate Nu permite duplicate
Păstrează ordinea Nu garantează ordine
Delete/insert ineficient Delete O(1)
OK pentru @OneToMany Recomandat pentru @ManyToMany

Când să folosești relații unidirecționale

Debugging relații

# Afișează SQL
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

# Logging detaliat
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE

# Statistici Hibernate
spring.jpa.properties.hibernate.generate_statistics=true

DTO Projections pentru performanță

Când ai nevoie doar de câteva câmpuri, evită încărcarea entităților complete:

// Interface projection
public interface DepartmentSummary {
    Long getId();
    String getName();
    int getEmployeeCount();
}

// Query
@Query("""
    SELECT d.id as id, d.name as name, COUNT(e) as employeeCount
    FROM Department d LEFT JOIN d.employees e
    GROUP BY d.id, d.name
    """)
List<DepartmentSummary> findAllSummaries();