@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"
)
)| Parametre | Açıklama |
|---|---|
name | Join table'ın adı |
joinColumns | Owner side'ın (bu entity) FK sütunu |
inverseJoinColumns | Karşı tarafın FK sütunu |
uniqueConstraints | Unique constraint tanımı |
indexes | Index 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:
@JoinTablebelirtmezseniz 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()vehashCode()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 haldeConcurrentModificationExceptionalı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?
| Durum | Tercih |
|---|---|
| Join table'da ek sütun yok | @ManyToMany yeterli |
| Join table'da ek sütun var | Intermediate entity kullan |
| İlişki sık değişiyor | Set kullan |
| Audit/log bilgisi gerekiyor | Intermediate entity kullan |
| İlişki üzerinde iş mantığı var | Intermediate entity kullan |
| Basit etiketleme (tag) sistemi | @ManyToMany yeterli |
| Kayıt tarihi, not, seviye gibi metadata | Intermediate entity şart |
💡 Best Practice: Gerçek projelerde
@ManyToManyyerine 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
@ManyToManyyeterli görünse bile intermediate entity ile başlamayı tercih edin
AI Asistan
Sorularını yanıtlamaya hazır