Spring Boot
ManyToMany
@ManyToMany maps a relationship where each instance of entity A can be associated with multiple instances of entity B, and vice versa. At the database level this requires a join table. JPA can manage the join table automatically, but for any join table that carries extra columns — created_at, status, quantity — you must model it as an explicit entity with @ManyToOne on both sides.
Basic @ManyToMany with @JoinTable
The simplest many-to-many: JPA manages the join table automatically. One side owns the join table (declares @JoinTable); the other is the inverse side (declares mappedBy). Use this only when the join table has no extra columns.
Java
// ── Owning side — declares @JoinTable: ────────────────────────────────
@Entity
@Table(name = "students")
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
// Owning side — defines the join table:
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "student_courses", // join table name
joinColumns = @JoinColumn(name = "student_id"), // FK to students
inverseJoinColumns = @JoinColumn(name = "course_id") // FK to courses
)
private Set<Course> courses = new HashSet<>();
// Use Set, not List — avoids Hibernate's duplicate-row issue with EAGER
// collections and prevents unintentional CartesianProduct in some queries.
// Convenience methods — synchronise both sides:
public void enrollIn(Course course) {
courses.add(course);
course.getStudents().add(this);
}
public void withdrawFrom(Course course) {
courses.remove(course);
course.getStudents().remove(this);
}
protected Student() { }
public Student(String name) { this.name = name; }
public Long getId() { return id; }
public String getName() { return name; }
public Set<Course> getCourses() { return Collections.unmodifiableSet(courses); }
}
// ── Inverse side — declares mappedBy: ────────────────────────────────
@Entity
@Table(name = "courses")
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
// Inverse side — no join table definition here:
@ManyToMany(mappedBy = "courses", fetch = FetchType.LAZY)
private Set<Student> students = new HashSet<>();
protected Course() { }
public Course(String title) { this.title = title; }
public Long getId() { return id; }
public String getTitle() { return title; }
public Set<Student> getStudents() { return Collections.unmodifiableSet(students); }
}Join Table with Extra Columns — Explicit Entity
As soon as the join table needs extra columns (enrolled_at, grade, status), model it as a dedicated entity with two @ManyToOne fields. This is the recommended approach for any non-trivial many-to-many — it gives full JPA feature support and explicit control.
Java
// ── Join table entity — owns both FK columns + extra data: ───────────
@Entity
@Table(name = "enrollments",
uniqueConstraints = @UniqueConstraint(
columnNames = {"student_id", "course_id"}))
public class Enrollment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "student_id", nullable = false)
private Student student;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "course_id", nullable = false)
private Course course;
// Extra columns on the join table:
@Column(nullable = false, updatable = false)
private LocalDate enrolledAt;
@Enumerated(EnumType.STRING)
private EnrollmentStatus status = EnrollmentStatus.ACTIVE;
@Column(precision = 4, scale = 2)
private BigDecimal grade;
protected Enrollment() { }
public Enrollment(Student student, Course course) {
this.student = student;
this.course = course;
this.enrolledAt = LocalDate.now();
}
public Long getId() { return id; }
public Student getStudent() { return student; }
public Course getCourse() { return course; }
public LocalDate getEnrolledAt() { return enrolledAt; }
public EnrollmentStatus getStatus() { return status; }
public void setStatus(EnrollmentStatus status) { this.status = status; }
public BigDecimal getGrade() { return grade; }
public void setGrade(BigDecimal grade) { this.grade = grade; }
public enum EnrollmentStatus { ACTIVE, COMPLETED, WITHDRAWN }
}
// ── Student — uses @OneToMany to Enrollment instead of @ManyToMany: ──
@Entity
@Table(name = "students")
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "student", cascade = CascadeType.ALL,
orphanRemoval = true, fetch = FetchType.LAZY)
private List<Enrollment> enrollments = new ArrayList<>();
public void enroll(Course course) {
Enrollment enrollment = new Enrollment(this, course);
enrollments.add(enrollment);
}
protected Student() { }
public Student(String name) { this.name = name; }
public Long getId() { return id; }
public String getName() { return name; }
public List<Enrollment> getEnrollments() {
return Collections.unmodifiableList(enrollments);
}
}
// ── Course — symmetric: ───────────────────────────────────────────────
@Entity
@Table(name = "courses")
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@OneToMany(mappedBy = "course", fetch = FetchType.LAZY)
private List<Enrollment> enrollments = new ArrayList<>();
protected Course() { }
public Course(String title) { this.title = title; }
public Long getId() { return id; }
public String getTitle() { return title; }
public List<Enrollment> getEnrollments() {
return Collections.unmodifiableList(enrollments);
}
}
// ── Repository: ───────────────────────────────────────────────────────
@Repository
public interface EnrollmentRepository extends JpaRepository<Enrollment, Long> {
List<Enrollment> findByStudentId(Long studentId);
List<Enrollment> findByCourseId(Long courseId);
Optional<Enrollment> findByStudentIdAndCourseId(Long studentId, Long courseId);
boolean existsByStudentIdAndCourseId(Long studentId, Long courseId);
@Query("SELECT e FROM Enrollment e " +
"JOIN FETCH e.course " +
"WHERE e.student.id = :studentId AND e.status = :status")
List<Enrollment> findByStudentAndStatus(
@Param("studentId") Long studentId,
@Param("status") Enrollment.EnrollmentStatus status);
}Fetching and N+1 with @ManyToMany
Many-to-many collections are lazy by default. JOIN FETCH loads both sides in one query. Using Set instead of List prevents Hibernate's infamous "HHH90003004" warning about multiple bag fetches and avoids accidental Cartesian products.
Java
// ── Repository — fetch strategies: ────────────────────────────────────
@Repository
public interface StudentRepository extends JpaRepository<Student, Long> {
// JOIN FETCH courses in one query:
@Query("SELECT DISTINCT s FROM Student s " +
"LEFT JOIN FETCH s.courses WHERE s.id = :id")
Optional<Student> findByIdWithCourses(@Param("id") Long id);
// EntityGraph alternative:
@EntityGraph(attributePaths = {"courses"})
Optional<Student> findWithCoursesById(Long id);
// For the explicit-entity pattern — fetch enrollments with courses:
@Query("SELECT s FROM Student s " +
"LEFT JOIN FETCH s.enrollments e " +
"LEFT JOIN FETCH e.course " +
"WHERE s.id = :id")
Optional<Student> findByIdWithEnrollments(@Param("id") Long id);
}
// ── Set vs List for @ManyToMany: ──────────────────────────────────────
// Use Set<> — avoids duplicate rows from JOIN and Hibernate bag warnings.
// Never use List<> for @ManyToMany unless you need ordered results
// (use @OrderBy or @OrderColumn in that case).
// ── WRONG — multiple List bag fetches (MultipleBagFetchException): ─────
// Cannot JOIN FETCH two List associations in one query:
@Query("SELECT s FROM Student s " +
"JOIN FETCH s.courses " +
"JOIN FETCH s.tags") // MultipleBagFetchException if both are List
Optional<Student> findWithCoursesAndTags(@Param("id") Long id);
// ── CORRECT — use Set for @ManyToMany, or fetch in two queries: ───────
// Option 1: Change both to Set<> → two JOIN FETCHes work.
// Option 2: Fetch in separate queries — Hibernate merges the results:
Optional<Student> findByIdWithCourses(@Param("id") Long id);
// Then in service:
student = studentRepository.findByIdWithCourses(id).orElseThrow();
// Hibernate second query loads tags via batch fetch (default_batch_fetch_size).Managing the Relationship in the Service Layer
The service layer is responsible for adding and removing associations, ensuring both sides are synchronised for bidirectional relationships, and using the correct repository methods to avoid unnecessary loads.
Java
@Service
@RequiredArgsConstructor
@Transactional
public class EnrollmentService {
private final StudentRepository studentRepository;
private final CourseRepository courseRepository;
private final EnrollmentRepository enrollmentRepository;
// ── Enrol a student (explicit entity pattern): ────────────────────
public EnrollmentResponse enrol(Long studentId, Long courseId) {
if (enrollmentRepository.existsByStudentIdAndCourseId(studentId, courseId)) {
throw new AlreadyEnrolledException(studentId, courseId);
}
Student student = studentRepository.getReferenceById(studentId);
Course course = courseRepository.getReferenceById(courseId);
Enrollment enrollment = new Enrollment(student, course);
return EnrollmentResponse.from(enrollmentRepository.save(enrollment));
}
// ── Withdraw a student: ────────────────────────────────────────────
public void withdraw(Long studentId, Long courseId) {
Enrollment enrollment = enrollmentRepository
.findByStudentIdAndCourseId(studentId, courseId)
.orElseThrow(() -> new EnrollmentNotFoundException(studentId, courseId));
enrollment.setStatus(Enrollment.EnrollmentStatus.WITHDRAWN);
// OR: enrollmentRepository.delete(enrollment); for hard delete
}
// ── Record grade: ──────────────────────────────────────────────────
public void recordGrade(Long studentId, Long courseId, BigDecimal grade) {
Enrollment enrollment = enrollmentRepository
.findByStudentIdAndCourseId(studentId, courseId)
.orElseThrow();
enrollment.setGrade(grade);
enrollment.setStatus(Enrollment.EnrollmentStatus.COMPLETED);
// Dirty checking generates UPDATE — no explicit save() needed
}
// ── Simple @ManyToMany pattern — add/remove tag: ───────────────────
public void addTag(Long studentId, Long tagId) {
Student student = studentRepository.findById(studentId).orElseThrow();
Tag tag = tagRepository.getReferenceById(tagId);
student.getTags().add(tag); // modifies the join table on flush
}
public void removeTag(Long studentId, Long tagId) {
Student student = studentRepository.findByIdWithTags(studentId).orElseThrow();
student.getTags().removeIf(t -> t.getId().equals(tagId));
}
}