Back to list
dev_to 2026年3月14日

JPA Mapping with Hibernate - 多対多関係

JPA Mapping with Hibernate- Many-to-Many Relationship

Translated: 2026/3/14 12:00:59
hibernatejpajava-entitydatabase-relationshipsoftware-engineering

Japanese Translation

前回では 1 対多および多対 1 の関係について議論しました。今回は多対多(Many-to-Many)関係を見てみましょう。Introduction 以下の項目について取り上げます。 Relational Model Behind @ManyToMany Unidirectional @ManyToMany — Simplest Form Bidirectional @ManyToMany equals() and hashCode() — The Critical Foundation Intermediate Entity for Join Table With Extra Columns Fetch Strategies & The N+1 Problem Cascade Types — What to Use and When Serialization — Avoiding Infinite Recursion Performance Best Practices Quick Reference — Best Practices vs Pitfalls Conclusion 多対多(M:N)関係は、関係型モデルにおいて最も一般的なものの一つですが、最も誤解されがちな関係です。JPA/Hibernate で不注意にマッピングすると、N+1 クエリ問題、無限の JSON 再帰、不要な早期読み込み、および微妙なデータ整合性バグの原因となります。この記事では、最も単純な 1 方向の形式から完全な中間実体アプローチまで、@ManyToMany のすべての重要な側面を順に解説し、アプリケーションのパフォーマンスと保守性を保つためのベストプラクティスを各概念と対比させて提示します。 関係型データベースでは、M:N 関係は常にジョイントテーブル(ブリッジテーブルや関連テーブルとも呼ばれる)を通じて実装されます。例えば、Student ↔ Course の関係は student_course というジョイントテーブルを必要とします。 students ────────────── ───────────────── ────────────────── student_id (PK) student_id (FK) ─▶ course_id (PK) student_course courses ────────────────────────── name course_id (FK) ─▶ title email enrolled_at credits grade もしジョイントテーブルに追加のカラム(enrolled_at, grade)を含める場合、それは個別の実体としてモデル化されなければなりません。単純な @JoinTable アノテーションだけではそのカラムをキャッチすることはできません。一方の側だけが他方へナビゲートする必要がある場合、かつジョイントテーブルに追加カラムがない場合は、このアプローチを使用してください。 @Entity public class Student { @Id @GeneratedValue private Long id; private String name; @ManyToMany @JoinTable( name = "student_course", joinColumns = @JoinColumn(name = "student_id"), inverseJoinColumns = @JoinColumn(name = "course_id") ) private Set courses = new HashSet<>(); // ← Set を使用し、List には使わない } @Entity public class Course { @Id @GeneratedValue private Long id; private String title; // 逆参照がない → 非方向性 } ✅ ベストプラクティス — Set を List として使用する @ManyToMany 集合体には常に Set<> を使用してください。Hibernate は List<> を使用する場合、同じクエリで複数の バグ(bag)集合体を読み取る際に MultipleBagFetchException を発生させる可能性があります。また、重複するレコードを生成する可能性があります。 双方向マッピングでは、両側が互いに他方へナビゲートします。ちょうど一方だけが保有側(@JoinTable を保持する)であり、もう一方は逆側(mappedBy を使用する)です。 @Entity public class Student { // 保有側 @ManyToMany @JoinTable( name = "student_course", joinColumns = @JoinColumn(name = "student_id"), inverseJoinColumns = @JoinColumn(name = "course_id") ) private Set courses = new HashSet<>(); } @Entity public class Course { // 逆側 @ManyToMany(mappedBy = "courses") // mappedBy は必須条件 private Set students = new HashSet<>(); } ❌ 落とし穴 — mappedBy を忘れる 逆側に mappedBy を宣言しないと、JPA は独立した 2 つのジョイントテーブルを生成し、すべてのリンク行を 2 回挿入してしまいます。ちょうど一方にのみ mappedBy を宣言してください。 双方向関係では、メモリ内のステートは flush されるまでデータベースステートと独立しているため、ヘルパーメソッドの中で両方をプログラム的に更新する必要があります。 // 保有側の利便性を提供するためのメソッド public void enroll(Course course) { this.courses.add(course); course.getStudents().add(this); // 逆側を同期 } public void unenroll(Course course) { this.courses.remove(course); course.getStudents().remove(this); } Hibernate は equals() と hashCode() を使用して、2 つのエンティティインスタンスが同じ行を表すかどうかを判定します。特に Set 集合体に要素を追加/削除する際や、ディタッチエンティティをマージする際にこれを果たします。デフォルトのオブジェクトアイデンティティ実装はこの機能をすべて破損させてしまいます。 @Entity public class Course { @Id @GeneratedValue private Long id; @N

Original Content

In the previous section, we discussed the One-to-Many and Many-to-One Relationship Now, let’s look at the Many-to-many relationship Introduction The Relational Model Behind @ManyToMany Unidirectional @ManyToMany — Simplest Form Bidirectional @ManyToMany equals() and hashCode() — The Critical Foundation Intermediate Entity for Join Table With Extra Columns Fetch Strategies & The N+1 Problem Cascade Types — What to Use and When Serialization — Avoiding Infinite Recursion Performance Best Practices Quick Reference — Best Practices vs Pitfalls Conclusion A many-to-many (M:N) relationship is one of the most common yet most misunderstood associations in relational modeling. When mapped carelessly in JPA/Hibernate, it becomes a prime source of N+1 query problems, infinite JSON recursion, unnecessary eager loading, and subtle data-integrity bugs. This article walks through every important aspect of @ManyToMany, from the simplest unidirectional form to a full intermediate-entity approach, and pairs every concept with the best practices that keep your application performant and maintainable. In a relational database, an M:N relationship is always implemented via a join table (also called a bridge or association table). For example, a Student ↔ Course relationship requires a student_course join table: students student_course courses ────────────── ───────────────── ────────────────── student_id (PK) student_id (FK) ─▶ course_id (PK) name course_id (FK) ─▶ title email enrolled_at credits grade If the join table carries extra columns (enrolled_at, grade), you must model it as a separate entity — a plain @JoinTable annotation cannot capture those columns. Use this when only one side needs to navigate to the other, and the join table has no extra columns. @Entity public class Student { @Id @GeneratedValue private Long id; private String name; @ManyToMany @JoinTable( name = "student_course", joinColumns = @JoinColumn(name = "student_id"), inverseJoinColumns = @JoinColumn(name = "course_id") ) private Set courses = new HashSet<>(); // ← Set, never List } @Entity public class Course { @Id @GeneratedValue private Long id; private String title; // No back-reference here → unidirectional } ✅ Best Practice — Use Set, not List Always use Set<> for @ManyToMany collections. Hibernate's handling of List<> in many-to-many associations can throw MultipleBagFetchException when fetching multiple bag collections in the same query, and may produce duplicate records. Bidirectional mapping lets both sides navigate to each other. Exactly one side must be the owning side (holds @JoinTable); the other is the inverse side (uses mappedBy). @Entity public class Student { // OWNING SIDE @ManyToMany @JoinTable( name = "student_course", joinColumns = @JoinColumn(name = "student_id"), inverseJoinColumns = @JoinColumn(name = "course_id") ) private Set courses = new HashSet<>(); } @Entity public class Course { // INVERSE SIDE @ManyToMany(mappedBy = "courses") // mappedBy is mandatory private Set students = new HashSet<>(); } ❌ Pitfall — Forgetting mappedBy Without mappedBy on the inverse side, JPA creates two independent join tables and double-inserts every link row. Always declare mappedBy on exactly one side. In a bidirectional relationship you must update both sides programmatically in your helper methods, because the in-memory state is independent of the database state until flush: // Add a convenience method on the owning side public void enroll(Course course) { this.courses.add(course); course.getStudents().add(this); // keep inverse in sync } public void unenroll(Course course) { this.courses.remove(course); course.getStudents().remove(this); } Hibernate uses equals() and hashCode() to determine whether two entity instances represent the same row, especially when adding/removing from Set collections and when merging detached entities. The default Object identity implementation breaks all of this. @Entity public class Course { @Id @GeneratedValue private Long id; @NaturalId // Hibernate annotation @Column(nullable = false, unique = true) private String courseCode; // e.g. "CS-101" — stable business key @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Course)) return false; Course other = (Course) o; return Objects.equals(courseCode, other.courseCode); } @Override public int hashCode() { return Objects.hashCode(courseCode); // must be stable across states } } ❌ Pitfall — Using id for hashCode Never base hashCode on @Id if entities can be in a Set before being persisted. A transient entity has id = null, so its hashCode changes on persist, which silently corrupts any Set or HashMap that contained it. When the join table needs to store data (enrollment date, grade, seat number, etc.), replace the @ManyToMany shortcut with an explicit intermediate entity. This is the most robust and recommended pattern in production systems. @Embeddable public class EnrollmentId implements Serializable { @Column(name = "student_id") private Long studentId; @Column(name = "course_id") private Long courseId; // equals() + hashCode() required for @Embeddable PKs @Override public boolean equals(Object o) { ... } @Override public int hashCode() { ... } } @Entity @Table(name = "student_course") public class Enrollment { @EmbeddedId private EnrollmentId id = new EnrollmentId(); @ManyToOne(fetch = FetchType.LAZY) @MapsId("studentId") private Student student; @ManyToOne(fetch = FetchType.LAZY) @MapsId("courseId") private Course course; @Column(nullable = false) private LocalDate enrolledAt; private BigDecimal grade; } @Entity public class Student { @OneToMany(mappedBy = "student", cascade = CascadeType.ALL, orphanRemoval = true) private Set enrollments = new HashSet<>(); public void enroll(Course course, LocalDate date) { Enrollment e = new Enrollment(); e.setStudent(this); e.setCourse(course); e.setEnrolledAt(date); enrollments.add(e); } } @Entity public class Course { @OneToMany(mappedBy = "course") // no cascade from Course side private Set enrollments = new HashSet<>(); } ✅ Best Practice — Cascade only from the aggregate root Apply CascadeType.ALL + orphanRemoval only on the owning aggregate root side (Student). Do not cascade from Course — it is a separate aggregate and should not delete enrollments when a course is touched. Fetch strategy is the single most impactful performance decision in any JPA application. // ✅ Correct — LAZY is the safe default @ManyToMany(fetch = FetchType.LAZY) private Set courses = new HashSet<>(); // ❌ Wrong — loads ALL courses for ALL students every time a Student is loaded @ManyToMany(fetch = FetchType.EAGER) private Set courses; Even with LAZY loading, iterating a collection inside a loop produces one SQL query per iteration. Fix this with a JOIN FETCH JPQL query: // N+1 — fires one extra query per student List students = em.createQuery("SELECT s FROM Student s", Student.class) .getResultList(); students.forEach(s -> s.getCourses().size()); // N hits // ✅ Fixed — single JOIN query List students = em.createQuery( "SELECT DISTINCT s FROM Student s JOIN FETCH s.courses", Student.class).getResultList(); @ManyToMany(fetch = FetchType.LAZY) @BatchSize(size = 25) // loads 25 students' courses in one IN (...) query private Set courses = new HashSet<>(); ❌ Pitfall — MultipleBagFetchException You cannot JOIN FETCH two List<> collections in the same JPQL query. Hibernate throws MultipleBagFetchException. Fix: change both to Set<>, or fetch one in JPQL and use @BatchSize for the second. Cascade types control which JPA lifecycle operations (PERSIST, MERGE, REMOVE, etc.) are propagated from parent to child. Scenario Cascade orphanRemoval Notes Simple @ManyToMany (no extra cols) PERSIST, MERGE false Do NOT use REMOVE Intermediate entity (owned) ALL true Only from aggregate root Intermediate entity (shared) PERSIST, MERGE false Shared = don't remove Course → Enrollment (inverse) (none) false Let Student own it ❌ Pitfall — CascadeType.REMOVE on @ManyToMany Using CascadeType.REMOVE (or ALL) on a plain @ManyToMany will delete the related entities themselves — not just the join row. Removing one Student will delete all their Course records from the courses table, affecting every other enrolled student. Bidirectional relationships create circular object graphs. When Jackson (or any JSON library) tries to serialize a Student that contains Courses, which contain Students, which contain Courses... it throws a StackOverflowError. // On the owning side (Student) @JsonManagedReference private Set courses; // On the inverse side (Course) @JsonBackReference private Set students; // this side is NOT serialized Never serialize JPA entities directly to your API layer. Use dedicated DTO/response classes: // DTO — safe, no cycles, no Hibernate proxies public record CourseDTO(Long id, String title, int credits) { public static CourseDTO from(Course c) { return new CourseDTO(c.getId(), c.getTitle(), c.getCredits()); } } public record StudentDTO(Long id, String name, Set courses) { public static StudentDTO from(Student s) { return new StudentDTO( s.getId(), s.getName(), s.getCourses().stream().map(CourseDTO::from).collect(Collectors.toSet()) ); } } For read-heavy endpoints, skip entity loading entirely and query directly into DTOs: @Query("SELECT new com.example.dto.StudentCourseDTO(s.name, c.title) " + "FROM Student s JOIN s.courses c WHERE s.id = :studentId") List findCoursesByStudent(@Param("studentId") Long id); // ❌ Wrong — Hibernate loads ALL rows into memory, then paginates @Query("SELECT DISTINCT s FROM Student s JOIN FETCH s.courses") Page findAll(Pageable pageable); // issues HHH90003004 warning // ✅ Correct — paginate the root entity, load collection separately @Query(value = "SELECT s FROM Student s", countQuery = "SELECT COUNT(s) FROM Student s") Page findAll(Pageable pageable); // Then use @BatchSize or a second query to load courses Keep your transactions at the service layer where the full unit of work is clear. Opening a transaction in a repository method gives you no control over lazy loading in the service. ✅ Best Practice ❌ Pitfall to Avoid Use @ManyToMany with intermediate entity for extra columns Using plain @JoinTable when join table has extra data Set fetch = FetchType.LAZY on both sides Using FetchType.EAGER (causes N+1 queries) Define owning side clearly with mappedBy on inverse Bidirectional mapping without mappedBy Use Set<> instead of List<> to avoid duplicates Using List<> and getting MultipleBagFetchException Use orphanRemoval + CascadeType.ALL on parent side only Cascading ALL on both sides (infinite loops / dual deletes) Implement equals()/hashCode() based on business key Using default Object identity for equals/hashCode Use @BatchSize or JOIN FETCH to load related data Loading collections in a loop (classic N+1 problem) Use DTOs and projections for read-heavy queries Serializing full entity graphs to JSON (StackOverflow risk) Many-to-many associations are powerful but require deliberate design. The key takeaways are: Use Set<> — always. List<> in M:N leads to bags, duplicates, and MultipleBagFetchException. Prefer intermediate entity — as soon as the join table has any extra column, model it explicitly. Keep fetch=LAZY everywhere — solve loading problems with JOIN FETCH or @BatchSize, not EAGER. Define equals()/hashCode() on a stable business key — never rely on the database-generated id. Cascade carefully — REMOVE and orphanRemoval belong only on aggregate-root-owned children. Use DTOs at the API layer — never serialize entity graphs directly to JSON. Measure first — use Hibernate's statistics or a query logger (P6Spy/datasource-proxy) to confirm you have no N+1 queries before shipping.