Entity Relationships

Mapping associations between entities in JPA

← Back to Index

Understanding Relationships

In relational databases, tables are connected through foreign keys. In JPA, we map these connections as relationships between entity objects. Understanding relationships is crucial for designing efficient and maintainable data models.

Types of Relationships

Relationship Description Example
@OneToOne One entity relates to exactly one other User - UserProfile
@OneToMany One entity relates to many others Department - Employees
@ManyToOne Many entities relate to one Employees - Department
@ManyToMany Many entities relate to many others Students - Courses

Directionality

// UNIDIRECTIONAL: Only one side knows about the relationship
@Entity
public class Employee {
    @ManyToOne
    private Department department;  // Employee knows about Department
}

@Entity
public class Department {
    // Department doesn't know about Employees
}

// BIDIRECTIONAL: Both sides know about each other
@Entity
public class Employee {
    @ManyToOne
    private Department department;
}

@Entity
public class Department {
    @OneToMany(mappedBy = "department")
    private List<Employee> employees;  // Department also knows about Employees
}
Owning Side vs Inverse Side

In bidirectional relationships, one side "owns" the relationship (has the foreign key column). The inverse side uses mappedBy to indicate it's not the owner.

  • Owning side: The side that contains the foreign key (usually @ManyToOne)
  • Inverse side: Uses mappedBy attribute
  • Rule: Only changes to the owning side are persisted!

@OneToOne Relationship

A one-to-one relationship where each entity instance relates to exactly one instance of another entity.

Unidirectional @OneToOne

// User has one Profile
@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String username;

    @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "profile_id", referencedColumnName = "id")
    private UserProfile profile;
}

@Entity
@Table(name = "user_profiles")
public class UserProfile {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String fullName;
    private String bio;
    private String avatarUrl;
}

// Database tables:
// users: id, username, profile_id (FK)
// user_profiles: id, full_name, bio, avatar_url

Bidirectional @OneToOne

@Entity
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String username;

    @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "profile_id")
    private UserProfile profile;

    // Helper method to maintain bidirectional consistency
    public void setProfile(UserProfile profile) {
        if (profile == null) {
            if (this.profile != null) {
                this.profile.setUser(null);
            }
        } else {
            profile.setUser(this);
        }
        this.profile = profile;
    }
}

@Entity
public class UserProfile {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String fullName;

    @OneToOne(mappedBy = "profile")  // Inverse side
    private User user;
}

@OneToOne with Shared Primary Key

// Both entities share the same ID (more efficient, no extra FK column)
@Entity
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String username;

    @OneToOne(mappedBy = "user", cascade = CascadeType.ALL)
    @PrimaryKeyJoinColumn
    private UserProfile profile;
}

@Entity
public class UserProfile {

    @Id
    private Long id;  // Same ID as User!

    private String fullName;

    @OneToOne
    @MapsId  // Use User's ID as this entity's ID
    @JoinColumn(name = "id")
    private User user;
}

// Database tables:
// users: id, username
// user_profiles: id (PK and FK to users), full_name

@OneToMany and @ManyToOne

The most common relationship type. One entity (the "one" side) has many related entities (the "many" side).

Bidirectional @OneToMany / @ManyToOne (Recommended)

// Department has many Employees
@Entity
public class Department {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @OneToMany(
        mappedBy = "department",           // Points to field in Employee
        cascade = CascadeType.ALL,           // Cascade all operations
        orphanRemoval = true                 // Remove orphaned employees
    )
    private List<Employee> employees = new ArrayList<>();

    // Helper methods to maintain bidirectional consistency
    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 is recommended
    @JoinColumn(name = "department_id")    // FK column name
    private Department department;          // Owning side!
}

// Database tables:
// departments: id, name
// employees: id, name, department_id (FK to departments)

// Usage:
Department dept = new Department();
dept.setName("Engineering");

Employee emp1 = new Employee();
emp1.setName("Alice");
dept.addEmployee(emp1);  // Use helper method!

Employee emp2 = new Employee();
emp2.setName("Bob");
dept.addEmployee(emp2);

entityManager.persist(dept);  // Cascade saves employees too

Unidirectional @ManyToOne (Simple)

// Employee knows about Department, but not vice versa
@Entity
public class Employee {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "department_id", nullable = false)
    private Department department;
}

@Entity
public class Department {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    // No reference to employees
}

// To get employees of a department, use JPQL:
List<Employee> employees = em.createQuery(
    "SELECT e FROM Employee e WHERE e.department.id = :deptId",
    Employee.class
).setParameter("deptId", departmentId)
 .getResultList();

Unidirectional @OneToMany (Avoid!)

// NOT RECOMMENDED - Creates join table unnecessarily
@Entity
public class Department {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @OneToMany
    @JoinColumn(name = "department_id")  // Without this, creates join table!
    private List<Employee> employees;
}

// This works but is inefficient. Prefer bidirectional with @ManyToOne owning side.

@ManyToMany Relationship

Used when entities on both sides can have multiple related entities. Requires a join table.

Basic @ManyToMany

// Students can enroll in many Courses, Courses have many Students
@Entity
public class Student {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @ManyToMany
    @JoinTable(
        name = "student_courses",                        // Join table name
        joinColumns = @JoinColumn(name = "student_id"),   // FK to this entity
        inverseJoinColumns = @JoinColumn(name = "course_id")  // FK to other entity
    )
    private Set<Course> courses = new HashSet<>();

    // Helper methods
    public void enrollInCourse(Course course) {
        courses.add(course);
        course.getStudents().add(this);
    }

    public void dropCourse(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<>();
}

// Database tables:
// students: id, name
// courses: id, title
// student_courses: student_id (FK), course_id (FK) - join table!

@ManyToMany with Extra Columns (Intermediate Entity)

// When join table needs extra data (enrollment_date, grade, etc.)
// Use intermediate entity instead of @ManyToMany

@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 enrollInCourse(Course course, LocalDate enrollmentDate) {
        Enrollment enrollment = new Enrollment(this, course, enrollmentDate);
        enrollments.add(enrollment);
        course.getEnrollments().add(enrollment);
    }
}

@Entity
public class Course {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    @OneToMany(mappedBy = "course")
    private Set<Enrollment> enrollments = new HashSet<>();
}

// The intermediate entity
@Entity
@Table(name = "enrollments")
public class Enrollment {

    @EmbeddedId
    private EnrollmentId id;

    @ManyToOne(fetch = FetchType.LAZY)
    @MapsId("studentId")
    private Student student;

    @ManyToOne(fetch = FetchType.LAZY)
    @MapsId("courseId")
    private Course course;

    // Extra columns!
    private LocalDate enrollmentDate;
    private String grade;
    private Boolean completed;

    public Enrollment() {}

    public Enrollment(Student student, Course course, LocalDate enrollmentDate) {
        this.id = new EnrollmentId(student.getId(), course.getId());
        this.student = student;
        this.course = course;
        this.enrollmentDate = enrollmentDate;
    }
}

// Composite key for Enrollment
@Embeddable
public class EnrollmentId implements Serializable {

    private Long studentId;
    private Long courseId;

    public EnrollmentId() {}

    public EnrollmentId(Long studentId, Long courseId) {
        this.studentId = studentId;
        this.courseId = courseId;
    }

    // equals() and hashCode() REQUIRED!
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        EnrollmentId that = (EnrollmentId) o;
        return Objects.equals(studentId, that.studentId) &&
               Objects.equals(courseId, that.courseId);
    }

    @Override
    public int hashCode() {
        return Objects.hash(studentId, courseId);
    }
}

Self-Referential Relationships

When an entity references itself (e.g., employee-manager, category-subcategory).

// Employee with Manager (who is also an Employee)
@Entity
public class Employee {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "manager_id")
    private Employee manager;

    @OneToMany(mappedBy = "manager")
    private List<Employee> subordinates = new ArrayList<>();

    public void addSubordinate(Employee employee) {
        subordinates.add(employee);
        employee.setManager(this);
    }
}

// Database table:
// employees: id, name, manager_id (FK to employees.id)

// Category with parent/children (tree structure)
@Entity
public class Category {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "parent_id")
    private Category parent;

    @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL)
    private List<Category> children = new ArrayList<>();

    public boolean isRoot() {
        return parent == null;
    }

    public boolean isLeaf() {
        return children.isEmpty();
    }
}

Cascade Operations

Cascade types determine which operations propagate from parent to child entities.

// CascadeType options:

@OneToMany(cascade = CascadeType.PERSIST)  // persist() cascades
@OneToMany(cascade = CascadeType.MERGE)    // merge() cascades
@OneToMany(cascade = CascadeType.REMOVE)   // remove() cascades
@OneToMany(cascade = CascadeType.REFRESH)  // refresh() cascades
@OneToMany(cascade = CascadeType.DETACH)   // detach() cascades
@OneToMany(cascade = CascadeType.ALL)      // All of the above

// Multiple cascade types
@OneToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})

// Example: Order with OrderItems
@Entity
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToMany(
        mappedBy = "order",
        cascade = CascadeType.ALL,  // Save/delete items with order
        orphanRemoval = true        // Delete items removed from collection
    )
    private List<OrderItem> items = new ArrayList<>();
}

// orphanRemoval example:
Order order = em.find(Order.class, 1L);
order.getItems().remove(0);  // This item will be DELETED from DB!
// Without orphanRemoval, it would just set order_id to NULL
Cascade with Care!
  • CascadeType.REMOVE can accidentally delete more than intended
  • Don't cascade on @ManyToOne - deleting an employee shouldn't delete the department!
  • Use cascade for parent-child relationships where child can't exist without parent

Fetch Types and Lazy Loading

// FetchType.LAZY - Load on demand (recommended for collections)
@OneToMany(fetch = FetchType.LAZY)  // Default for @OneToMany, @ManyToMany
private List<Employee> employees;

// FetchType.EAGER - Load immediately
@ManyToOne(fetch = FetchType.EAGER)  // Default for @ManyToOne, @OneToOne
private Department department;

// BEST PRACTICE: Make everything LAZY
@ManyToOne(fetch = FetchType.LAZY)  // Override default EAGER
private Department department;

@OneToOne(fetch = FetchType.LAZY)   // Override default EAGER
private UserProfile profile;

// LazyInitializationException: Accessing lazy field outside session
Employee emp = em.find(Employee.class, 1L);
em.close();  // Session closed!
emp.getDepartment().getName();  // LazyInitializationException!

// Solutions:

// 1. JOIN FETCH in query
Employee emp = em.createQuery(
    "SELECT e FROM Employee e JOIN FETCH e.department WHERE e.id = :id",
    Employee.class
).setParameter("id", 1L).getSingleResult();
em.close();
emp.getDepartment().getName();  // Works! Already loaded.

// 2. Initialize before closing session
Employee emp = em.find(Employee.class, 1L);
Hibernate.initialize(emp.getDepartment());  // Force load
em.close();

// 3. @Transactional in Spring (keeps session open)
@Transactional
public EmployeeDTO getEmployeeWithDepartment(Long id) {
    Employee emp = repository.findById(id).orElseThrow();
    return new EmployeeDTO(emp, emp.getDepartment().getName());  // Works!
}

Best Practices

Do's

  • Prefer @ManyToOne as the owning side
  • Use helper methods to maintain bidirectional consistency
  • Make all relationships LAZY by default
  • Use JOIN FETCH to avoid N+1 problems
  • Prefer Set over List for @ManyToMany to avoid duplicates
  • Use intermediate entities for @ManyToMany with extra columns
  • Always implement equals() and hashCode() for composite keys

Don'ts

  • Don't use EAGER fetching unless absolutely necessary
  • Don't cascade REMOVE on @ManyToOne
  • Don't use unidirectional @OneToMany without @JoinColumn
  • Don't forget mappedBy on inverse side
  • Don't mix business logic in entity helper methods

Summary

  • @OneToOne: One-to-one mapping, consider shared primary key
  • @OneToMany / @ManyToOne: Most common, prefer bidirectional with @ManyToOne owning
  • @ManyToMany: Uses join table, consider intermediate entity for extra data
  • mappedBy: Indicates inverse (non-owning) side
  • cascade: Propagate operations to related entities
  • orphanRemoval: Delete children removed from collection
  • FetchType.LAZY: Default for collections, use for all relationships
  • Helper methods: Essential for maintaining bidirectional consistency