JPA Mapping with Hibernate- One-to-Many and Many-to-One Relationship

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 that Department is 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 mappedBy on the @OneToMany to declare the inverse side.
  • Always set @ManyToOne(fetch = FetchType.LAZY) — the default EAGER is a trap.
  • Write bidirectional helper methods to keep both sides in sync.
  • Use Set over List to 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);
    }
}