📖 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.
| 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.
🏆 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();
}
}
- 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
- @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!
}
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.
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<>();
}
- 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ă:
@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);
}
}
- 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.
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;
}
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 |
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!
// 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!
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);
}
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.
// 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)
- 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
- @ManyToOne — setează
fetch = LAZY - @OneToMany — folosește
mappedBy - Bidirecțional — creează helper methods
- Colecții — inițializează cu
new ArrayList<>() - equals/hashCode — bazate pe ID sau business key
- @ManyToMany — folosește Set, nu List
- Cascade — nu folosi REMOVE/ALL pe @ManyToMany
- 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
- Colecția părinte ar fi enormă (1000+ elemente)
- Nu ai nevoie să navighezi de la părinte la copii
- Vrei să păstrezi entitățile simple
- Poți obține copiii cu query:
findByParentId()
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();