DEV Community

Cover image for JPA Mapping with Hibernate- One-to-Many and Many-to-One Relationship
AnkitDevCode
AnkitDevCode

Posted on

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

Table of Contents

  1. What Are These Relationships?
  2. Setting Up a Bidirectional Relationship
  3. Unidirectional @OneToMany — Avoid It
  4. Best Practices
  5. Common Pitfalls
  6. Cascade Types Reference
  7. Quick Reference Table
  8. 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)
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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.


3. Unidirectional @OneToMany — Avoid It

// Looks clean but generates extra SQL
@OneToMany
@JoinColumn(name = "department_id")  // no mappedBy
private List<Employee> employees;
Enter fullscreen mode Exit fullscreen mode

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 UPDATE queries on every insert
  • Poor performance at scale
  • May silently create join tables if @JoinColumn is omitted
  • Harder to maintain relationship consistency
  • Limited query capability from the child side

Best Practice: Always use @ManyToOne as 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;
Enter fullscreen mode Exit fullscreen mode

@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<>();
Enter fullscreen mode Exit fullscreen mode

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 — updates both sides
department.addEmployee(employee);

// ❌ Bad — only updates the inverse side, FK not persisted
department.getEmployees().add(employee);
Enter fullscreen mode Exit fullscreen mode

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<>();
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

Fix 1 — JOIN FETCH in JPQL:

@Query("SELECT DISTINCT d FROM Department d LEFT JOIN FETCH d.employees")
List<Department> findAllWithEmployees();
Enter fullscreen mode Exit fullscreen mode

Fix 2 — @EntityGraph (Spring Data):

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

Fix 3 — @BatchSize (Hibernate):

@OneToMany(mappedBy = "department")
@BatchSize(size = 25)
private Set<Employee> employees;
Enter fullscreen mode Exit fullscreen mode

@BatchSize loads children in batches using an IN (...) 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!
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 via em.remove().
  • orphanRemoval = true — deletes children when they are removed from the parent's collection or when the parent is deleted.

Use orphanRemoval = true when the parent fully owns the child lifecycle — it covers the REMOVE case 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 EAGERalways 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 mappedBy on @OneToMany to declare the inverse side and avoid a spurious join table.
  • Always set @ManyToOne(fetch = FetchType.LAZY) — the default EAGER is a performance trap.
  • Write bidirectional helper methods (addEmployee / removeEmployee) to keep both sides in sync.
  • Use Set<> over List<> 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 = true over CascadeType.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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)