← Kursa Dön
📄 Text · 18 min

@ManyToMany & @JoinTable — Çoka-Çok İlişkiler

Giriş — Neden Bu Konu Önemli?

Gerçek dünyada birçok ilişki çoka-çok yapısındadır: bir öğrenci birden fazla ders alabilir ve bir derse birden fazla öğrenci kayıt olabilir; bir kitabın birden fazla yazarı, bir yazarın birden fazla kitabı olabilir; bir ürünün birden fazla etiketi olabilir ve bir etiket birden fazla ürüne ait olabilir.

İlişkisel veritabanlarında çoka-çok ilişki doğrudan temsil edilemez — arada bir join table (birleştirme tablosu) gereklidir. JPA'da bu ilişkiyi @ManyToMany ve @JoinTable annotation'ları ile modelleriz.

Gerçek Hayat Analojisi: Bir üniversite sistemini düşünün. Öğrenci kayıt defteri (join table), her satırda "hangi öğrenci hangi derse kayıt olmuş" bilgisini tutar. Bu defter olmadan öğrenci-ders eşleştirmesi yapılamaz. Eğer deftere "kayıt tarihi" ve "not" gibi ek bilgiler de eklerseniz, artık basit bir defter değil, tam teşekküllü bir kayıt sisteminiz (intermediate entity) olur.


Temel @ManyToMany Yapısı

Entity Tanımları

@Entity
public class Student {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private String email;

    @ManyToMany
    @JoinTable(
        name = "student_course",                            // Join table adı
        joinColumns = @JoinColumn(name = "student_id"),     // Bu entity'nin FK'si
        inverseJoinColumns = @JoinColumn(name = "course_id") // Karşı entity'nin FK'si
    )
    private Set<Course> courses = new HashSet<>();

    // Helper Methods
    public void enrollCourse(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;
    private int credits;

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

Bu yapı veritabanında üç tablo oluşturur:

┌──────────────┐     ┌──────────────────┐     ┌──────────────┐
│   student    │     │  student_course  │     │    course    │
├──────────────┤     ├──────────────────┤     ├──────────────┤
│ id (PK)      │◄────│ student_id (FK)  │     │ id (PK)      │
│ name         │     │ course_id (FK)   │────►│ title        │
│ email        │     └──────────────────┘     │ credits      │
└──────────────┘                              └──────────────┘

@JoinTable Detaylı

@JoinTable annotation'ı, join table'ın yapısını kontrol eder:

@JoinTable(
    name = "student_course",
    joinColumns = @JoinColumn(
        name = "student_id",
        foreignKey = @ForeignKey(name = "fk_sc_student")
    ),
    inverseJoinColumns = @JoinColumn(
        name = "course_id",
        foreignKey = @ForeignKey(name = "fk_sc_course")
    ),
    uniqueConstraints = @UniqueConstraint(
        columnNames = {"student_id", "course_id"},
        name = "uk_student_course"
    )
)
ParametreAçıklama
nameJoin table'ın adı
joinColumnsOwner side'ın (bu entity) FK sütunu
inverseJoinColumnsKarşı tarafın FK sütunu
uniqueConstraintsUnique constraint tanımı
indexesIndex tanımları (performans için)

joinColumns, @JoinTable'ı taşıyan entity'nin (owner side) foreign key'ini belirtir. inverseJoinColumns ise karşı tarafın foreign key'ini belirtir. İsimlendirme kuralı: join = bu taraf, inverse = karşı taraf.

💡 İpucu: @JoinTable belirtmezseniz JPA otomatik isim üretir: <owner_table>_<inverse_table> formatında. Ancak bu isimlerin projenizdeki konvansiyona uyması nadirdir — her zaman açıkça belirtin.


Set vs List — Performans Farkı

@ManyToMany ilişkilerde Set kullanmak, List kullanmaktan çok daha performanslıdır. Bu fark, Hibernate'in koleksiyonları yönetme biçimiyle ilgilidir:

// ❌ List kullanımı — performans felaketi
@ManyToMany
@JoinTable(name = "student_course")
private List<Course> courses = new ArrayList<>();

// ✅ Set kullanımı — çok daha performanslı
@ManyToMany
@JoinTable(name = "student_course")
private Set<Course> courses = new HashSet<>();

Neden Set?

Hibernate, List kullanıldığında bir eleman eklendiğinde veya çıkarıldığında join table'daki tüm kayıtları siler ve yeniden ekler:

List ile bir eleman çıkarma:
1. DELETE FROM student_course WHERE student_id = 1  (tüm kayıtlar!)
2. INSERT INTO student_course (student_id, course_id) VALUES (1, 2)
3. INSERT INTO student_course (student_id, course_id) VALUES (1, 3)
4. INSERT INTO student_course (student_id, course_id) VALUES (1, 4)
... 99 tane daha INSERT

Set ile bir eleman çıkarma:
1. DELETE FROM student_course WHERE student_id = 1 AND course_id = 5  (sadece 1 kayıt!)

100 kayıt varsa ve 1 tane çıkarmak isterseniz: List ile 100 DELETE + 99 INSERT, Set ile 1 DELETE. Bu fark büyük veri setlerinde dramatik performans etkisi yaratır.

⚠️ Dikkat: Set kullandığınızda equals() ve hashCode() metodlarını doğru implement etmelisiniz. Auto-generated ID kullanmayın — entity persist edilmeden önce ID null olur ve Set düzgün çalışmaz.

@Entity
public class Course {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String title;

    // ✅ Business key ile equals/hashCode
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Course course)) return false;
        return title != null && title.equals(course.title);
    }

    @Override
    public int hashCode() {
        return Objects.hash(title);
    }
}

Senkronizasyon Helper Metodları

Bidirectional @ManyToMany ilişkilerde de senkronizasyon şarttır:

// Student sınıfında (owner side)
public void enrollCourse(Course course) {
    this.courses.add(course);
    course.getStudents().add(this);
}

public void dropCourse(Course course) {
    this.courses.remove(course);
    course.getStudents().remove(this);
}

// Tüm kurslardan çıkma
public void dropAllCourses() {
    for (Course course : new HashSet<>(courses)) {
        dropCourse(course);
    }
}

⚠️ Dikkat: dropAllCourses() metodunda iterasyon sırasında koleksiyonu değiştirdiğimiz için kopya üzerinden iterasyon yapıyoruz (new HashSet<>(courses)). Aksi halde ConcurrentModificationException alırsınız.


Intermediate Entity (Ara Entity) Yaklaşımı

Eğer join table'da ek sütunlar taşımanız gerekiyorsa (örneğin kayıt tarihi, not, seviye), @ManyToMany yetersiz kalır. Bu durumda join table'ı ayrı bir entity olarak modellersiniz:

Analoji: Başlangıçta basit bir kayıt defteriniz vardı (sadece öğrenci-ders eşleştirmesi). Ama zamanla deftere "kayıt tarihi", "aldığı not", "devam durumu" gibi bilgiler de eklemek istediniz. Artık defter basit bir çizgi değil, kendi alanları olan bir kayıt formuna dönüştü.

Composite Key ile Intermediate Entity

@Entity
@Table(name = "enrollment")
public class Enrollment {

    @EmbeddedId
    private EnrollmentId id;

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

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

    // Ek sütunlar — @ManyToMany ile bunlar mümkün değil!
    @Column(name = "enrollment_date")
    private LocalDate enrollmentDate = LocalDate.now();

    private String grade;

    @Enumerated(EnumType.STRING)
    private EnrollmentStatus status = EnrollmentStatus.ACTIVE;

    // Constructors
    protected Enrollment() {} // JPA için

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

@Embeddable
public class EnrollmentId implements Serializable {

    @Column(name = "student_id")
    private Long studentId;

    @Column(name = "course_id")
    private Long courseId;

    protected EnrollmentId() {}

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

    // equals() ve hashCode() ZORUNLU
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof EnrollmentId that)) return false;
        return Objects.equals(studentId, that.studentId)
            && Objects.equals(courseId, that.courseId);
    }

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

public enum EnrollmentStatus {
    ACTIVE, DROPPED, COMPLETED
}

Bu yapıda Student ↔ Enrollment ve Course ↔ Enrollment arasında @OneToMany / @ManyToOne ilişkisi kurulur:

@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 enroll(Course course) {
        Enrollment enrollment = new Enrollment(this, course);
        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<>();
}

Auto-Generated ID ile Alternatif

Composite key yerine auto-generated ID kullanan daha basit bir yapı:

@Entity
@Table(
    name = "enrollment",
    uniqueConstraints = @UniqueConstraint(
        columnNames = {"student_id", "course_id"},
        name = "uk_enrollment"
    )
)
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;

    private LocalDate enrollmentDate = LocalDate.now();
    private String grade;
}

Bu yaklaşım daha basittir — @EmbeddedId ve @MapsId karmaşıklığını ortadan kaldırır. Unique constraint ile aynı öğrencinin aynı derse iki kez kayıt olması engellenir.


Ne Zaman @ManyToMany, Ne Zaman Intermediate Entity?

DurumTercih
Join table'da ek sütun yok@ManyToMany yeterli
Join table'da ek sütun varIntermediate entity kullan
İlişki sık değişiyorSet kullan
Audit/log bilgisi gerekiyorIntermediate entity kullan
İlişki üzerinde iş mantığı varIntermediate entity kullan
Basit etiketleme (tag) sistemi@ManyToMany yeterli
Kayıt tarihi, not, seviye gibi metadataIntermediate entity şart

💡 Best Practice: Gerçek projelerde @ManyToMany yerine intermediate entity yaklaşımını tercih edin. Başlangıçta ek sütun gerekmese bile, ileride gerekeceği neredeyse kesindir. Ayrıca intermediate entity ile ilişki üzerinde daha fazla kontrol sahibi olursunuz — sorgulama, sayfalama, filtreleme kolaylaşır.


Gerçek Dünya Örneği: Etiket Sistemi

Basit bir etiketleme sistemi @ManyToMany için ideal bir kullanım alanıdır:

@Entity
public class Article {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String title;
    private String content;

    @ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
    @JoinTable(
        name = "article_tag",
        joinColumns = @JoinColumn(name = "article_id"),
        inverseJoinColumns = @JoinColumn(name = "tag_id")
    )
    private Set<Tag> tags = new HashSet<>();

    public void addTag(Tag tag) {
        tags.add(tag);
        tag.getArticles().add(this);
    }

    public void removeTag(Tag tag) {
        tags.remove(tag);
        tag.getArticles().remove(this);
    }
}

@Entity
public class Tag {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false)
    private String name;

    @ManyToMany(mappedBy = "tags")
    private Set<Article> articles = new HashSet<>();

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Tag tag)) return false;
        return name != null && name.equals(tag.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name);
    }
}

Repository ve Sorgulama

public interface ArticleRepository extends JpaRepository<Article, Long> {

    // Belirli bir etikete sahip makaleleri getir
    @Query("SELECT DISTINCT a FROM Article a JOIN a.tags t WHERE t.name = :tagName")
    List<Article> findByTagName(@Param("tagName") String tagName);

    // Birden fazla etiketten herhangi birine sahip makaleler
    @Query("SELECT DISTINCT a FROM Article a JOIN a.tags t WHERE t.name IN :tagNames")
    List<Article> findByAnyTag(@Param("tagNames") List<String> tagNames);

    // Tüm etiketlere sahip makaleler (AND mantığı)
    @Query("""
        SELECT a FROM Article a
        JOIN a.tags t
        WHERE t.name IN :tagNames
        GROUP BY a
        HAVING COUNT(DISTINCT t) = :tagCount
    """)
    List<Article> findByAllTags(@Param("tagNames") List<String> tagNames,
                                @Param("tagCount") long tagCount);

    // N+1 çözümü — etiketleri de tek seferde çek
    @Query("SELECT DISTINCT a FROM Article a LEFT JOIN FETCH a.tags")
    List<Article> findAllWithTags();
}

Yaygın Hatalar

1. @ManyToMany'de CascadeType.REMOVE Kullanmak

// ❌ TEHLİKELİ — Article silindiğinde Tag da silinir!
@ManyToMany(cascade = CascadeType.ALL)  // ALL = REMOVE dahil
private Set<Tag> tags;

// Senaryo: "Java" etiketi 100 makaleye atanmış
// Bir makale silindiğinde "Java" etiketi de silinir → 99 makale etiket kaybeder!

// ✅ GÜVENLİ — sadece PERSIST ve MERGE
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
private Set<Tag> tags;

2. Sonsuz Döngü (JSON/toString)

// ❌ Jackson sonsuz döngü
@Entity
public class Student {
    @ManyToMany
    private Set<Course> courses;  // → Course.students → Student.courses → ...
}

// ✅ @JsonIgnore veya DTO kullanın
@Entity
public class Course {
    @JsonIgnore
    @ManyToMany(mappedBy = "courses")
    private Set<Student> students;
}

3. Lazy Loading ve N+1

// ❌ N+1 — her öğrenci için ayrı sorgu
List<Student> students = studentRepo.findAll();
for (Student s : students) {
    System.out.println(s.getCourses().size()); // Her biri ayrı SELECT!
}

// ✅ JOIN FETCH
@Query("SELECT DISTINCT s FROM Student s LEFT JOIN FETCH s.courses")
List<Student> findAllWithCourses();

Performans ve İleri Konular

Fetch Stratejisi

@ManyToMany ilişkilerde default fetch type LAZY'dir — bu doğru bir default'tur. Ancak LAZY kullanırken dikkatli olmanız gereken noktalar var:

// Default: LAZY — erişilene kadar yüklenmez
@ManyToMany(fetch = FetchType.LAZY)
private Set<Course> courses;

// ❌ EAGER yapmayın — tüm ilişkili veriler her zaman yüklenir
@ManyToMany(fetch = FetchType.EAGER)
private Set<Course> courses;  // Her Student sorgusu tüm Course'ları da çeker!

Pagination ve @ManyToMany

@ManyToMany ilişkili entity'leri sayfalama ile çekmek istediğinizde JOIN FETCH ve Pageable birlikte sorun yaratır. Hibernate, in-memory sayfalama yapar ve şu uyarıyı verir:

HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory!

Çözüm: iki ayrı sorgu kullanın:

public interface StudentRepository extends JpaRepository<Student, Long> {

    // 1. Adım: Sayfalama ile sadece ID'leri çek
    @Query("SELECT s.id FROM Student s WHERE s.name LIKE %:name%")
    Page<Long> findStudentIds(@Param("name") String name, Pageable pageable);

    // 2. Adım: ID'lere göre kurslarla birlikte çek (sayfalama yok)
    @Query("SELECT DISTINCT s FROM Student s LEFT JOIN FETCH s.courses WHERE s.id IN :ids")
    List<Student> findStudentsWithCoursesByIds(@Param("ids") List<Long> ids);
}

// Service'te birleştirme
public Page<Student> searchStudentsWithCourses(String name, Pageable pageable) {
    Page<Long> idPage = studentRepo.findStudentIds(name, pageable);
    List<Student> students = studentRepo.findStudentsWithCoursesByIds(idPage.getContent());
    return new PageImpl<>(students, pageable, idPage.getTotalElements());
}

Çift Yönlü Silme İşlemi

Bir entity'yi silmeden önce tüm @ManyToMany ilişkilerini temizlemelisiniz:

@Service
@Transactional
public class StudentService {

    public void deleteStudent(Long studentId) {
        Student student = studentRepo.findById(studentId)
            .orElseThrow(() -> new ResourceNotFoundException("Student not found"));

        // ❌ Doğrudan silmek FK constraint hatası verir
        // studentRepo.delete(student);

        // ✅ Önce ilişkileri temizle
        for (Course course : new HashSet<>(student.getCourses())) {
            student.dropCourse(course);  // Helper method ile her iki tarafı da güncelle
        }
        studentRepo.delete(student);  // Artık güvenle silinebilir
    }
}

Bidirectional @ManyToMany ile Sıralama

Join table üzerinde sıralama yapmak istiyorsanız @OrderBy annotation'ını kullanabilirsiniz:

@Entity
public class Student {
    @ManyToMany
    @JoinTable(name = "student_course",
        joinColumns = @JoinColumn(name = "student_id"),
        inverseJoinColumns = @JoinColumn(name = "course_id"))
    @OrderBy("title ASC")  // Kursları başlığa göre sırala
    private Set<Course> courses = new LinkedHashSet<>();
    // LinkedHashSet → sıralamayı korur
}

Index Ekleme

Büyük veri setlerinde join table'a index eklemek sorgu performansını artırır:

@JoinTable(
    name = "student_course",
    joinColumns = @JoinColumn(name = "student_id"),
    inverseJoinColumns = @JoinColumn(name = "course_id"),
    indexes = {
        @Index(name = "idx_sc_student", columnList = "student_id"),
        @Index(name = "idx_sc_course", columnList = "course_id")
    }
)

Özet

  • @ManyToMany çoka-çok ilişki için kullanılır — JPA otomatik olarak join table oluşturur

  • @JoinTable ile join table'ın adı, FK sütunları ve constraint'leri kontrol edilir

  • Set kullanın, List kullanmayın — performans farkı dramatiktir (List ile tüm kayıtlar silinip yeniden eklenir)

  • equals/hashCode Set kullanıldığında zorunludur — auto-generated ID yerine business key kullanın

  • Intermediate entity join table'da ek sütun gerektiğinde veya ilişki üzerinde iş mantığı olduğunda tercih edilir

  • CascadeType.REMOVE @ManyToMany'de asla kullanılmamalı — paylaşılan entity'ler silinir

  • Gerçek projelerde başlangıçta @ManyToMany yeterli görünse bile intermediate entity ile başlamayı tercih edin