Many-to-Many Relationship Mapping in JPA

Illustration for Many-to-Many Relationship Mapping in JPA
By Last updated:

In relational databases, there are cases where entities have many-to-many relationships. For example:

  • A Student can enroll in multiple Courses.
  • A Course can have multiple Students.

In JPA, this association is represented with the @ManyToMany annotation. This mapping allows developers to avoid writing complex join tables manually, letting JPA handle the underlying join management.

In this tutorial, we’ll explore Many-to-Many mapping in JPA, including setup, CRUD operations, queries, pitfalls, and best practices.


Core Definition of Many-to-Many Mapping

  • A Many-to-Many relationship exists when multiple entities of one type relate to multiple entities of another type.
  • Implemented in DB using a join table.
  • Can be unidirectional or bidirectional.

Setup and Configuration

persistence.xml

<persistence xmlns="https://jakarta.ee/xml/ns/persistence" version="3.0">
    <persistence-unit name="my-pu" transaction-type="RESOURCE_LOCAL">
        <class>com.example.Student</class>
        <class>com.example.Course</class>
        <properties>
            <property name="jakarta.persistence.jdbc.driver" value="org.h2.Driver"/>
            <property name="jakarta.persistence.jdbc.url" value="jdbc:h2:mem:testdb"/>
            <property name="jakarta.persistence.jdbc.user" value="sa"/>
            <property name="jakarta.persistence.jdbc.password" value=""/>
            <property name="hibernate.hbm2ddl.auto" value="update"/>
            <property name="hibernate.show_sql" value="true"/>
        </properties>
    </persistence-unit>
</persistence>

Entity Mapping with @ManyToMany

Example: Student ↔ Course

import jakarta.persistence.*;
import java.util.HashSet;
import java.util.Set;

@Entity
@Table(name = "students")
public class Student {

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

    private String name;

    @ManyToMany(cascade = { CascadeType.PERSIST, CascadeType.MERGE })
    @JoinTable(
        name = "student_courses",
        joinColumns = @JoinColumn(name = "student_id"),
        inverseJoinColumns = @JoinColumn(name = "course_id")
    )
    private Set<Course> courses = new HashSet<>();

    // Getters and setters
}

@Entity
@Table(name = "courses")
public class Course {

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

    private String title;

    @ManyToMany(mappedBy = "courses")
    private Set<Student> students = new HashSet<>();

    // Getters and setters
}

Key Points

  • @ManyToMany defines the relationship.
  • @JoinTable specifies the join table and its columns.
  • mappedBy makes the relationship bidirectional.
  • Cascade ensures persistence propagates properly.

CRUD Operations with EntityManager

Create (Insert)

EntityManagerFactory emf = Persistence.createEntityManagerFactory("my-pu");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();

tx.begin();
Course java = new Course();
java.setTitle("Java");

Course sql = new Course();
sql.setTitle("SQL");

Student s1 = new Student();
s1.setName("Alice");
s1.getCourses().add(java);
s1.getCourses().add(sql);

Student s2 = new Student();
s2.setName("Bob");
s2.getCourses().add(java);

java.getStudents().add(s1);
sql.getStudents().add(s1);
java.getStudents().add(s2);

em.persist(s1);
em.persist(s2);
tx.commit();

SQL Generated:

insert into courses (title) values ('Java');
insert into courses (title) values ('SQL');
insert into students (name) values ('Alice');
insert into students (name) values ('Bob');
insert into student_courses (student_id, course_id) values (1, 1);
insert into student_courses (student_id, course_id) values (1, 2);
insert into student_courses (student_id, course_id) values (2, 1);

Read

Student found = em.find(Student.class, 1L);
System.out.println(found.getName() + " enrolled in " + found.getCourses().size() + " courses");

Update

tx.begin();
Course python = new Course();
python.setTitle("Python");
found.getCourses().add(python);
python.getStudents().add(found);
em.merge(found);
tx.commit();

SQL:

insert into courses (title) values ('Python');
insert into student_courses (student_id, course_id) values (1, 3);

Delete

tx.begin();
em.remove(found);
tx.commit();

SQL:

delete from student_courses where student_id=?;
delete from students where id=?;

Querying with JPA

JPQL

List<Student> students = em.createQuery("SELECT s FROM Student s JOIN FETCH s.courses", Student.class).getResultList();

Criteria API

CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Student> cq = cb.createQuery(Student.class);
Root<Student> root = cq.from(Student.class);
root.fetch("courses", JoinType.INNER);
List<Student> results = em.createQuery(cq).getResultList();

Native SQL

List<Object[]> results = em.createNativeQuery("SELECT s.name, c.title FROM students s JOIN student_courses sc ON s.id = sc.student_id JOIN courses c ON c.id = sc.course_id").getResultList();

Persistence Context and Fetching

  • Many-to-Many defaults to LAZY fetching.
  • Use EAGER only when always needed.
  • Think of LAZY as ordering food when hungry vs EAGER as ordering everything upfront.
@ManyToMany(fetch = FetchType.LAZY)
private Set<Course> courses;

Real-World Use Cases

  • Students ↔ Courses in academic systems.
  • Authors ↔ Books in publishing.
  • Users ↔ Roles in authentication/authorization.

Anti-Patterns and Pitfalls

  • Avoid unidirectional Many-to-Many → creates poor readability.
  • Watch out for N+1 problem when fetching collections.
  • Don’t abuse CascadeType.REMOVE → may delete unrelated entities.

Best Practices

  • Use bidirectional mapping for clarity.
  • Default to LAZY fetching.
  • Always manage both sides of the relationship (add/remove).
  • Prefer intermediate entity (e.g., Enrollment) if extra attributes are needed.

📌 JPA Version Notes

  • JPA 2.0: Introduced Criteria API, Metamodel.
  • JPA 2.1: Added entity graphs, stored procedures.
  • Jakarta Persistence: Package moved from javax.persistencejakarta.persistence.

Conclusion and Key Takeaways

  • Many-to-Many models complex associations naturally.
  • Use @JoinTable to map join tables.
  • Keep collections LAZY to improve performance.
  • Use an intermediate entity if attributes exist in join tables.

By mastering Many-to-Many mapping, you’ll design scalable, maintainable, and production-ready JPA applications.


FAQ

1. What’s the difference between JPA and Hibernate?
JPA is a specification, Hibernate is one implementation.

2. How does JPA handle the persistence context?
It manages entity state across transactions.

3. What are the drawbacks of eager fetching in JPA?
Loads unnecessary data and impacts performance.

4. How can I solve the N+1 select problem with JPA?
Use JOIN FETCH or batch fetching.

5. Can I use JPA without Hibernate?
Yes, with EclipseLink or OpenJPA.

6. What’s the best strategy for inheritance mapping in JPA?
JOINED for normalization, SINGLE_TABLE for performance.

7. How does JPA handle composite keys?
With @IdClass or @EmbeddedId.

8. What changes with Jakarta Persistence?
The package namespace moved to jakarta.persistence.

9. Is JPA suitable for microservices?
Yes, but DTOs are recommended for communication.

10. When should I avoid using JPA?
In heavy batch jobs or NoSQL-dominant architectures.