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
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.
| 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 | mappedBy is used on the inverse (non-owning) side. (Inverse side) |
| 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 |
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.
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
- 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. Prefer bidirectional mappings.
Problems with unidirectional @OneToMany:
- Generates extra UPDATE queries
- Poor performance
- May create join tables
- Harder to maintain relationship consistency
- Limited query capability
- Therefore:
Best practice is to use @ManyToOne as the owning side and @OneToMany(mappedBy) as the inverse side.
Best Practices
1. Always Use Lazy Fetching
@ManyToOne defaults to EAGER — a hidden performance trap. 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.
@OneToMany(mappedBy = "department")
private Set<Employee> employees = new HashSet<>();
This requires a proper equals and hashCode implementation 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
department.addEmployee(employee);
// Bad — only updates the inverse side
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.
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
@ManyToOne(cascade = CascadeType.ALL) // could delete the department!
private Department department;
// CORRECT — no cascade on the child side
@ManyToOne(fetch = FetchType.LAZY)
private Department department;
| Cascade Type | Effect |
|---|---|
| PERSIST | When parent is saved for the first time, unsaved children are also saved |
| MERGE | When parent is merged (updated), children are also merged |
| REMOVE | When parent is deleted, all children are deleted — use with care |
| REFRESH | When parent is refreshed from DB, children are also refreshed |
| DETACH | When parent is detached from context, children are also detached |
| ALL | Shorthand for all of the above — convenient but can be dangerous |
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();
}
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 with JOIN FETCH:
@Query("SELECT DISTINCT d FROM Department d LEFT JOIN FETCH d.employees")
List<Department> findAllWithEmployees();
Fix with @EntityGraph (Spring Data):
@EntityGraph(attributePaths = {"employees"})
List<Department> findAll();
Fix with @BatchSize (Hibernate):
@OneToMany(mappedBy = "department")
@BatchSize(size = 25)
private Set<Employee> employees;
@BatchSize loads children in batches rather than one query each — a good low-friction option when you don’t want to change your 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 closed
department.getEmployees().size(); // LazyInitializationException!
Fix: Always access lazy relationships within a @Transactional context, or eagerly fetch what you need in the query.
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.
// Only sets one side — department.getEmployees() won't contain emp in memory
emp.setDepartment(department);
Fix: Use the helper addEmployee / removeEmployee methods shown above.
CascadeType.REMOVE + orphanRemoval Redundancy
Both cause child deletion when the parent is removed. Using both is redundant and signals misunderstanding. Use orphanRemoval = true when the parent fully owns the child lifecycle — it covers the removal case and also handles disassociation from the collection.
Quick Reference Table
| Attribute | Recommended | 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 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 |
Summary
- The FK lives on the many side — that entity is the owner.
- Use
mappedByon the@OneToManyto declare the inverse side. - Always set
@ManyToOne(fetch = FetchType.LAZY)— the defaultEAGERis a trap. - Write bidirectional helper methods to keep both sides in sync.
- Use
SetoverListto avoid Cartesian products. - Watch for the N+1 problem whenever you iterate over collections.
- Never cascade from child to parent.
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", "[email protected]");
Employee bob = new Employee("Bob", "[email protected]");
/*
* 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);
}
}