In the previous section, we discussed the One-to-One Relationship Now, let’s look at the one-to-many and many-to-one relationships
Table of Contents
- What Are These Relationships?
- Setting Up a Bidirectional Relationship
- Unidirectional @OneToMany — Avoid It
- Best Practices
- Common Pitfalls
- Cascade Types Reference
- Quick Reference Table
- Summary
1. What Are These Relationships?
In JPA, One-to-Many and Many-to-One are two sides of the same coin. A Department can have many Employee records, and each Employee belongs to one Department. In the database, the foreign key (department_id) lives on the employees table — making Employee the owning side.
Key Terminology
| Term | Meaning |
|---|---|
| Owning side | The entity that holds the foreign key column in the database — always the @ManyToOne side |
| Inverse side | The entity with mappedBy — does not control the FK, just navigates the relationship |
mappedBy |
Used on the inverse (non-owning) side to point back to the owning field |
CascadeType |
Which operations (PERSIST, MERGE, REMOVE, etc.) propagate from parent to child |
orphanRemoval |
Automatically deletes a child row when it is removed from the parent collection |
FetchType.LAZY |
Child data is loaded on demand when accessed — default for collections |
FetchType.EAGER |
Child data is always loaded with the parent — default for single associations |
Database Representation
departments employees
──────────────── ──────────────────────
dept_id (PK) ◀─ employee_id (PK)
name name
department_id (FK) ← FK lives here (owning side)
2. Setting Up a Bidirectional Relationship
The recommended approach is a bidirectional mapping, where both entities know about each other.
The Parent (One Side)
@Entity
public class Department {
@Id
@GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "department", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<Employee> employees = new HashSet<>();
// Always use helper methods to keep both sides in sync
public void addEmployee(Employee emp) {
employees.add(emp);
emp.setDepartment(this);
}
public void removeEmployee(Employee emp) {
employees.remove(emp);
emp.setDepartment(null);
}
}
The Child (Many Side)
@Entity
public class Employee {
@Id
@GeneratedValue
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY) // Always override the default!
@JoinColumn(name = "department_id")
private Department department;
}
Why
mappedBy?
It tells JPA thatDepartmentis the inverse side — it does not own the FK column. Without it, Hibernate creates a join table, which is almost never what you want.
3. Unidirectional @OneToMany — Avoid It
// Looks clean but generates extra SQL
@OneToMany
@JoinColumn(name = "department_id") // no mappedBy
private List<Employee> employees;
Here, Department knows about Employee, but Employee does not reference Department. When Hibernate cannot write the FK during the child INSERT (because it doesn't own the column), it issues extra UPDATE statements afterward. This produces unnecessary SQL and hurts performance.
Problems with unidirectional @OneToMany:
- Generates extra
UPDATEqueries on every insert - Poor performance at scale
- May silently create join tables if
@JoinColumnis omitted - Harder to maintain relationship consistency
- Limited query capability from the child side
✅ Best Practice: Always use
@ManyToOneas the owning side and@OneToMany(mappedBy = "...")as the inverse side for bidirectional relationships.
4. Best Practices
1. Always Use Lazy Fetching
@ManyToOne defaults to EAGER — a hidden performance trap that loads the parent every time you load a child, even when you don't need it. Always override it explicitly.
@ManyToOne(fetch = FetchType.LAZY) // override the EAGER default
@JoinColumn(name = "department_id")
private Department department;
@OneToMany is lazy by default, which is correct — leave it as-is.
2. Use Set Instead of List
When you JOIN FETCH two @OneToMany collections on the same entity, using List causes a Cartesian product that multiplies result rows. A Set deduplicates them automatically.
@OneToMany(mappedBy = "department")
private Set<Employee> employees = new HashSet<>();
This requires a proper
equalsandhashCodeimplementation based on a business key, not the database ID.
3. Keep Both Sides in Sync
In a bidirectional relationship, JPA uses the owning side to write to the DB. If you only update the inverse side (Department.employees), the FK won't be persisted. Always use helper methods:
// ✅ Good — updates both sides
department.addEmployee(employee);
// ❌ Bad — only updates the inverse side, FK not persisted
department.getEmployees().add(employee);
4. Use orphanRemoval for Parent-Owned Children
When the parent fully owns the lifecycle of its children, enable orphanRemoval:
@OneToMany(mappedBy = "department", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<Employee> employees = new HashSet<>();
Removing a child from the collection now automatically deletes it from the database on the next flush — no need for an explicit em.remove() call.
5. Never Cascade on @ManyToOne
cascade = CascadeType.ALL belongs on the parent (@OneToMany) side. Adding it to @ManyToOne can cause catastrophic side effects — like deleting a Department when you delete one Employee.
// ❌ WRONG — could delete the entire department!
@ManyToOne(cascade = CascadeType.ALL)
private Department department;
// ✅ CORRECT — no cascade on the child side
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "department_id")
private Department department;
6. Override equals and hashCode
Use a natural business key (e.g., email for an employee), not the database ID. The ID is null before the entity is persisted, so ID-based equality breaks collections like HashSet.
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Employee)) return false;
Employee other = (Employee) o;
return email != null && email.equals(other.email);
}
@Override
public int hashCode() {
return getClass().hashCode(); // stable across transient and persistent states
}
5. Common Pitfalls
The N+1 Query Problem
The most common JPA performance issue. Loading a list of departments, then accessing each one's employees, fires one query per department.
// ❌ Triggers 1 + N queries
List<Department> depts = repo.findAll();
depts.forEach(d -> d.getEmployees().size()); // N extra queries!
Fix 1 — JOIN FETCH in JPQL:
@Query("SELECT DISTINCT d FROM Department d LEFT JOIN FETCH d.employees")
List<Department> findAllWithEmployees();
Fix 2 — @EntityGraph (Spring Data):
@EntityGraph(attributePaths = {"employees"})
List<Department> findAll();
Fix 3 — @BatchSize (Hibernate):
@OneToMany(mappedBy = "department")
@BatchSize(size = 25)
private Set<Employee> employees;
@BatchSizeloads children in batches using anIN (...)clause rather than one query each — a low-friction option when you don't want to change your JPQL queries.
LazyInitializationException
Accessing a lazy collection outside of an active Hibernate session (e.g., after the transaction has closed) throws this exception.
// ❌ Fails if the session is already closed
department.getEmployees().size(); // LazyInitializationException!
Fix: Always access lazy relationships within a @Transactional context, or eagerly fetch what you need in the repository query.
// ✅ Safe — transaction is open for the duration of the method
@Transactional
public DepartmentDTO getDepartmentWithEmployees(Long id) {
Department dept = repo.findById(id).orElseThrow();
dept.getEmployees().size(); // safe here
return DepartmentDTO.from(dept);
}
Out-of-Sync Bidirectional State
Setting the child's reference without updating the parent's collection (or vice versa) leaves the in-memory object graph inconsistent, even if the DB is correct after flush.
// ❌ Only sets one side — department.getEmployees() won't contain emp in memory
emp.setDepartment(department);
// ✅ Use the helper method to keep both sides consistent
department.addEmployee(emp);
CascadeType.REMOVE + orphanRemoval Redundancy
Both cause child deletion when the parent is removed. Using both together is redundant and signals a misunderstanding of their purpose.
-
CascadeType.REMOVE— deletes children when the parent entity is explicitly removed viaem.remove(). -
orphanRemoval = true— deletes children when they are removed from the parent's collection or when the parent is deleted.
✅ Use
orphanRemoval = truewhen the parent fully owns the child lifecycle — it covers theREMOVEcase and also handles disassociation from the collection. You do not need both.
6. Cascade Types Reference
| Cascade Type | Effect | Use When |
|---|---|---|
PERSIST |
When parent is saved for the first time, unsaved children are also saved | Always safe on parent side |
MERGE |
When parent is merged (updated), children are also merged | Almost always paired with PERSIST
|
REMOVE |
When parent is deleted, all children are deleted | Only on owned children; use orphanRemoval instead |
REFRESH |
When parent is refreshed from DB, children are also refreshed | Rarely needed |
DETACH |
When parent is detached from context, children are also detached | Rarely needed |
ALL |
Shorthand for all of the above | Convenient but potentially dangerous — review carefully |
7. Quick Reference Table
| Attribute | Recommended Setting | Notes |
|---|---|---|
@OneToMany fetch |
LAZY (default)
|
Don't override unless necessary |
@ManyToOne fetch |
LAZY (explicit)
|
Default is EAGER — always override
|
mappedBy |
On the @OneToMany side |
Marks the inverse (non-owning) side |
cascade |
CascadeType.ALL on parent only |
Never put cascade on @ManyToOne
|
orphanRemoval |
true for fully owned children |
Handles both removal and disassociation |
| Collection type | Set<T> |
Safer than List<T> with multiple joins |
| N+1 prevention |
JOIN FETCH or @EntityGraph
|
@BatchSize is a low-friction alternative |
equals/hashCode
|
Based on business key | Never based on database ID |
Unidirectional @OneToMany
|
Avoid | Extra UPDATE SQL, harder to maintain |
| Helper methods | Always define on parent | Keep both sides of bidirectional mapping in sync |
8. Summary
- The FK lives on the many side — that entity is the owning side.
- Use
mappedByon@OneToManyto declare the inverse side and avoid a spurious join table. - Always set
@ManyToOne(fetch = FetchType.LAZY)— the defaultEAGERis a performance trap. - Write bidirectional helper methods (
addEmployee/removeEmployee) to keep both sides in sync. - Use
Set<>overList<>to avoid Cartesian products when joining multiple collections. - Watch for the N+1 problem whenever you iterate over collections — fix with
JOIN FETCH,@EntityGraph, or@BatchSize. -
Never cascade from child to parent (
@ManyToOne). - Prefer
orphanRemoval = trueoverCascadeType.REMOVE— it handles more cases cleanly. - Base
equals()/hashCode()on a stable business key, never on the auto-generated database ID.
Code
@Entity
@Getter
@Setter
public class Department {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "department", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<Employee> employees = new HashSet<>();
// Always use helper methods to keep both sides in sync
public void addEmployee(Employee emp) {
employees.add(emp);
emp.setDepartment(this);
}
public void removeEmployee(Employee emp) {
employees.remove(emp);
emp.setDepartment(null);
}
}
@Entity
@Getter
@Setter
public class Employee {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
public Employee(String name, String email) {
this.name = name;
this.email = email;
}
@ManyToOne(fetch = FetchType.LAZY) // Always override the default!
@JoinColumn(name = "department_id")
private Department department;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Employee)) return false;
Employee other = (Employee) o;
return email != null && email.equals(other.email);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}
public interface DepartmentRepository extends JpaRepository<Department, Long> {
}
public interface EmployeeRepository extends JpaRepository<Employee, Long> {
}
@Component
public class DataLoader implements ApplicationRunner {
private final DepartmentRepository departmentRepo;
private final EmployeeRepository employeeRepo;
public DataLoader(DepartmentRepository departmentRepo, EmployeeRepository employeeRepo) {
this.departmentRepo = departmentRepo;
this.employeeRepo = employeeRepo;
}
@Override
@Transactional
public void run(ApplicationArguments args) {
/*
* STEP 1: Create a Department entity
* No DB query yet because the entity is only created in memory.
*/
Department engineering = new Department();
engineering.setName("Engineering");
/*
* STEP 2: Create Employee entities
* These are also only in memory at this point.
*/
Employee alice = new Employee("Alice", "alice@example.com");
Employee bob = new Employee("Bob", "bob@example.com");
/*
* STEP 3: Link employees to department
* The helper method usually:
* 1. Adds employee to department.employees list
* 2. Sets employee.department = this
*
* This ensures both sides of the bidirectional relationship stay consistent.
*/
engineering.addEmployee(alice);
engineering.addEmployee(bob);
/*
* STEP 4: Persist department
*
* Because the Department entity likely has:
*
* @OneToMany(mappedBy="department", cascade = CascadeType.ALL, orphanRemoval = true)
*
* Hibernate will cascade the persist operation to Employee entities.
*
* Expected SQL queries:
*
* INSERT INTO department (name)
* INSERT INTO employee (name, email, department_id)
* INSERT INTO employee (name, email, department_id)
*
* Total queries = 3
*/
departmentRepo.save(engineering);
/*
* STEP 5: Read all departments
*
* Expected SQL:
* SELECT * FROM department
*
* If employees collection is LAZY (recommended),
* employees are NOT fetched until accessed.
*/
departmentRepo.findAll().forEach(d ->
System.out.println("Dept: " + d.getName())
);
/*
* STEP 6: Find department by ID
*
* Expected SQL:
* SELECT * FROM department WHERE id = ?
*/
Optional<Department> dept = departmentRepo.findById(engineering.getId());
dept.ifPresent(d -> System.out.println("Found: " + d.getName()));
System.out.println("Department loaded");
// employees should load only here
dept.get().getEmployees().forEach(e ->
System.out.println(e.getName()));
/*
* STEP 7: Update employee
*
* Changing Alice's name marks the entity as dirty.
*
* Expected SQL on flush/commit:
* UPDATE employee SET name='Alice Smith' WHERE id=?
*/
alice.setName("Alice Smith");
employeeRepo.save(alice);
/*
* STEP 8: Remove employee from department
*
* Helper method typically:
* 1. Removes Bob from department.employees list
* 2. Sets bob.department = null
*
* Because orphanRemoval = true:
* Hibernate will DELETE the employee record automatically.
*
* Expected SQL:
* DELETE FROM employee WHERE id = ?
*/
engineering.removeEmployee(bob);
/*
* Saving department ensures Hibernate detects the orphan removal.
*/
departmentRepo.save(engineering);
}
}
Top comments (0)