← Kursa Dön
📄 Text · 20 min

@OneToMany & @ManyToOne — Bidirectional İlişkiler

Giriş — Neden Bu Konu Önemli?

Bir e-ticaret sitesini düşünün: bir müşterinin birden fazla siparişi vardır, bir kategorinin altında birden fazla ürün bulunur, bir blog yazarının birden fazla makalesi olabilir. Tüm bu senaryolar aynı ilişki türüne dayanır: bir-çok ilişkisi (one-to-many).

İlişkisel veritabanlarının gücü, tablolar arasındaki bağlantılardadır. JPA dünyasında en sık karşılaşılan ilişki türü @OneToMany / @ManyToOne ilişkisidir. Bu iki annotation bir bütünün iki yüzü gibidir — biri "bir" tarafını, diğeri "çok" tarafını temsil eder.

Bu ilişkiyi doğru kurmak, JPA ile çalışmanın temel taşıdır. Yanlış kurulan bir ilişki, gereksiz join table'lar, performans sorunları ve veri tutarsızlıklarına yol açar. Bu derste ilişki kavramlarını, owner/inverse side ayrımını, helper metodları, unidirectional vs bidirectional seçimini ve gerçek dünya senaryolarını derinlemesine inceleyeceğiz.

Gerçek Hayat Analojisi: Bir şirket ile çalışanları arasındaki ilişkiyi düşünün. Bir şirketin birden fazla çalışanı vardır (one-to-many), ancak her çalışan sadece bir şirkette çalışır (many-to-one). Çalışanın kimlik kartında "şirket adı" yazar — yani foreign key çalışan tarafındadır. JPA'da da foreign key'i taşıyan taraf (çalışan) owner side olur.


Temel Kavramlar

İlişkisel Veritabanında Bir-Çok İlişkisi

İlişkisel veritabanlarında bir-çok ilişkisi foreign key (yabancı anahtar) ile temsil edilir. "Çok" tarafındaki tablo, "bir" tarafının primary key'ini referans eden bir sütun taşır.

┌─────────────────┐       ┌─────────────────────┐
│   department    │       │     employee        │
├─────────────────┤       ├─────────────────────┤
│ id (PK)         │◄──────│ department_id (FK)   │
│ name            │       │ id (PK)             │
└─────────────────┘       │ name                │
                          │ email               │
                          └─────────────────────┘

JPA'da bu ilişkiyi iki annotation ile modelliyoruz:

  • @ManyToOne: "Çok" tarafına konur (foreign key'i taşıyan tablo — Employee)

  • @OneToMany: "Bir" tarafına konur (referans edilen tablo — Department)

Owner Side vs Inverse Side

JPA ilişkilerinde en kritik kavram owner side (sahip taraf) ve inverse side (ters taraf) ayrımıdır. Bu kavramı anlamadan JPA ilişkilerini doğru kurmak mümkün değildir.

Owner side, ilişkiyi veritabanında fiziksel olarak yöneten taraftır — yani foreign key sütununu taşıyan tablodur. JPA, ilişki değişikliklerini yalnızca owner side üzerinden algılar ve veritabanına yansıtır.

Inverse side ise ilişkinin diğer ucudur. mappedBy özelliği ile owner side'a işaret eder. Inverse side üzerinde yapılan değişiklikler veritabanına yansımaz — bu en sık yapılan hatalardan biridir.

Analoji: Bir evlilik cüzdanı düşünün. Evlilik tek bir kayıt defterinde tutulur — her iki taraf da evlidir, ama resmi kayıt tek bir yerdedir. İlişkiyi değiştirmek istiyorsanız (boşanma), resmi kaydın bulunduğu yere gitmeniz gerekir. JPA'da da ilişkiyi değiştirmek için owner side'ı güncellemeniz gerekir.


Bidirectional İlişki Kurulumu

Temel Entity Tanımları

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

    private String name;

    // Inverse side — mappedBy ile owner side'daki field adına referans verir
    @OneToMany(mappedBy = "department", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Employee> employees = new ArrayList<>();

    // Helper method — her iki tarafı da senkronize eder
    public void addEmployee(Employee employee) {
        employees.add(employee);
        employee.setDepartment(this);
    }

    public void removeEmployee(Employee employee) {
        employees.remove(employee);
        employee.setDepartment(null);
    }

    // Getter/Setter
}

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

    private String name;
    private String email;

    // Owner side — foreign key bu tabloda
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "department_id")
    private Department department;

    // Getter/Setter
}

Bu yapıda dikkat edilmesi gereken noktalar:

  • Employee entity'si owner side'dır çünkü @JoinColumn ile foreign key sütununu taşır

  • Department entity'si inverse side'dır çünkü mappedBy = "department" ile owner'a referans verir

  • mappedBy değeri, Employee sınıfındaki Java field adıdır (veritabanı sütun adı değil!)

  • Koleksiyon her zaman initialize edilmelidir (new ArrayList<>()) — NullPointerException önlenir

mappedBy Detaylı

mappedBy özelliği, inverse side'da kullanılır ve owner side'daki Java field adını belirtir. Yukarıdaki örnekte mappedBy = "department" ifadesi, Employee sınıfındaki department field'ına referans verir.

// Employee sınıfındaki bu field'a referans veriyoruz:
private Department department;  // ← mappedBy = "department"

mappedBy kullanmazsanız JPA, ilişkiyi unidirectional kabul eder ve otomatik olarak bir join table oluşturur. Bu genellikle istenmeyen bir davranıştır:

// ❌ mappedBy YOK — JPA bir join table oluşturur!
@OneToMany
private List<Employee> employees;
// → department_employees(department_id, employees_id) tablosu oluşur

// ✅ mappedBy VAR — foreign key doğrudan employee tablosunda
@OneToMany(mappedBy = "department")
private List<Employee> employees;
// → employee tablosundaki department_id sütunu kullanılır

@JoinColumn Detaylı

@JoinColumn annotation'ı, foreign key sütununun adını ve özelliklerini belirler:

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(
    name = "department_id",           // FK sütun adı
    nullable = false,                 // NULL olamaz — her çalışan bir departmana ait
    foreignKey = @ForeignKey(name = "fk_emp_dept"),  // FK constraint adı
    updatable = false                 // departman sonradan değiştirilemez (isteğe bağlı)
)
private Department department;

@JoinColumn belirtmezseniz JPA, <field_adı>_<referans_pk> formatında otomatik isim üretir (örneğin department_id). Ancak açıkça belirtmek best practice'tir — veritabanı şemanız üzerinde tam kontrol sahibi olursunuz.

@JoinColumn'un Önemli Özellikleri

ÖzellikVarsayılanAçıklama
name<field>_<pk>FK sütun adı
nullabletrueNULL kabul eder mi?
uniquefalseUnique constraint (OneToOne için)
insertabletrueINSERT'te dahil edilsin mi?
updatabletrueUPDATE'te değiştirilebilsin mi?
columnDefinition-DDL column tanımı

Senkronizasyon Helper Metodları

Bidirectional ilişkilerde her iki tarafı da senkronize etmek zorunludur. Sadece bir tarafı güncellemek tutarsızlığa yol açar:

// ❌ YANLIŞ — sadece inverse side güncelleniyor
department.getEmployees().add(employee);
// → Veritabanına yansımaz! Çünkü JPA sadece owner side'ı dinler.

// ❌ YANLIŞ — sadece owner side güncelleniyor
employee.setDepartment(department);
// → Veritabanına yansır AMA aynı persistence context'teki
//   department.getEmployees() listesi tutarsız kalır!

// ✅ DOĞRU — her iki taraf da güncelleniyor
department.getEmployees().add(employee);
employee.setDepartment(department);

// ✅ EN İYİ — helper method kullanımı
department.addEmployee(employee);

Helper metodlar, bu senkronizasyonu otomatikleştirerek hata yapma olasılığını ortadan kaldırır:

@Entity
public class Department {
    // ... diğer alanlar

    public void addEmployee(Employee employee) {
        employees.add(employee);
        employee.setDepartment(this);
    }

    public void removeEmployee(Employee employee) {
        employees.remove(employee);
        employee.setDepartment(null);
    }

    // Toplu ekleme
    public void addEmployees(List<Employee> newEmployees) {
        for (Employee emp : newEmployees) {
            addEmployee(emp);
        }
    }
}

⚠️ Dikkat: removeEmployee metodu çağrıldığında orphanRemoval = true aktifse, çıkarılan employee veritabanından silinir. orphanRemoval = false ise sadece foreign key NULL yapılır. Bu davranış farkını iyi anlayın!


Unidirectional vs Bidirectional

JPA'da bir-çok ilişkisini üç farklı şekilde kurabilirsiniz:

1. Unidirectional @ManyToOne (Sadece Child → Parent)

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "department_id")
    private Department department;
}

@Entity
public class Department {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    // employees listesi YOK
}

En basit ve en performanslı seçenek. Departman üzerinden çalışanlara erişim gerekmiyorsa bu yeterlidir. Gerçekten lazım olduğunda JPQL ile çekebilirsiniz: SELECT e FROM Employee e WHERE e.department.id = :deptId

2. Unidirectional @OneToMany (Sadece Parent → Children)

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

    @OneToMany
    @JoinColumn(name = "department_id") // mappedBy YOK, @JoinColumn VAR
    private List<Employee> employees = new ArrayList<>();
}

@Entity
public class Employee {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    // department referansı YOK
}

⚠️ Dikkat: @JoinColumn olmadan JPA bir join table oluşturur — bu genellikle istenmeyen bir davranıştır! @JoinColumn ekleyerek foreign key'in employee tablosunda olmasını sağlayabilirsiniz. Ancak bu durumda bile performans sorunları olabilir: Hibernate, child eklerken önce INSERT (department_id=NULL), sonra UPDATE (department_id=X) yapar — yani ekstra SQL statement çalışır.

3. Bidirectional (Her İki Taraftan Erişim)

// Department → employees (inverse side, mappedBy)
// Employee → department (owner side, @JoinColumn)

En yaygın ve önerilen kullanım. Her iki taraftan da navigasyon mümkündür.

Karşılaştırma Tablosu

ÖzellikUnidirectional @ManyToOneUnidirectional @OneToManyBidirectional
FK yönetimiDoğrudanEkstra UPDATEDoğrudan
Parent → Child❌ (JPQL ile mümkün)
Child → Parent
Performans⭐⭐⭐⭐⭐⭐
KarmaşıklıkDüşükOrtaOrta
Önerilen?✅ İlk tercih❌ Kaçının✅ Gerektiğinde

FetchType: LAZY vs EAGER

@ManyToOne ve @OneToMany ilişkilerinde veri ne zaman yüklenecek? Bu, fetch parametresi ile kontrol edilir:

// @ManyToOne → default EAGER (her zaman yüklenir)
// @OneToMany → default LAZY (erişildiğinde yüklenir)

// ✅ Best practice: her zaman LAZY kullanın
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "department_id")
private Department department;

@OneToMany(mappedBy = "department", fetch = FetchType.LAZY)
private List<Employee> employees;

Neden LAZY? Eager fetching, ihtiyaç olmayan veriyi de yükler. 100 çalışan çekerken her birinin departmanını da yüklemek, gereksiz JOIN ve bellek tüketimine neden olur. LAZY ile veriye sadece erişildiğinde sorgu atılır.

💡 İpucu: @ManyToOne ilişkisinin default'u EAGER'dır — bu JPA spesifikasyonunun bir tasarım hatasıdır. Her @ManyToOne ilişkisine fetch = FetchType.LAZY eklemeyi alışkanlık haline getirin.


Gerçek Dünya Örneği: Blog Uygulaması

Birden fazla kavramı birleştiren bütünleşik bir örnek:

@Entity
@Table(name = "authors")
public class Author {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

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

    @OneToMany(
        mappedBy = "author",
        cascade = {CascadeType.PERSIST, CascadeType.MERGE},
        orphanRemoval = true,
        fetch = FetchType.LAZY
    )
    @OrderBy("createdAt DESC")  // Son yazılar önce
    private List<Post> posts = new ArrayList<>();

    // --- Helper Methods ---
    public void addPost(Post post) {
        posts.add(post);
        post.setAuthor(this);
    }

    public void removePost(Post post) {
        posts.remove(post);
        post.setAuthor(null);
    }

    public int getPostCount() {
        return posts.size();
    }
}

@Entity
@Table(name = "posts")
public class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String title;

    @Column(columnDefinition = "TEXT")
    private String content;

    @Enumerated(EnumType.STRING)
    private PostStatus status = PostStatus.DRAFT;

    @Column(name = "created_at", updatable = false)
    private LocalDateTime createdAt = LocalDateTime.now();

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(
        name = "author_id",
        nullable = false,
        foreignKey = @ForeignKey(name = "fk_post_author")
    )
    private Author author;

    // equals ve hashCode — business key kullanımı
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Post post)) return false;
        return title != null && title.equals(post.title)
            && createdAt != null && createdAt.equals(post.createdAt);
    }

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

public enum PostStatus {
    DRAFT, PUBLISHED, ARCHIVED
}

Repository ve Service Katmanı

public interface PostRepository extends JpaRepository<Post, Long> {

    // N+1 problemi çözümü — JOIN FETCH
    @Query("SELECT p FROM Post p JOIN FETCH p.author WHERE p.status = :status")
    List<Post> findByStatusWithAuthor(@Param("status") PostStatus status);

    // Sadece gerekli alanları çekmek
    @Query("SELECT p.title, p.createdAt, p.author.name FROM Post p WHERE p.status = 'PUBLISHED'")
    List<Object[]> findPublishedPostSummaries();

    // Sayfalama ile
    Page<Post> findByAuthorId(Long authorId, Pageable pageable);
}

@Service
@Transactional
public class BlogService {

    private final AuthorRepository authorRepository;
    private final PostRepository postRepository;

    public BlogService(AuthorRepository authorRepository, PostRepository postRepository) {
        this.authorRepository = authorRepository;
        this.postRepository = postRepository;
    }

    public Post createPost(Long authorId, String title, String content) {
        Author author = authorRepository.findById(authorId)
            .orElseThrow(() -> new ResourceNotFoundException("Author not found: " + authorId));

        Post post = new Post();
        post.setTitle(title);
        post.setContent(content);

        // Helper method ile her iki tarafı senkronize et
        author.addPost(post);

        // CascadeType.PERSIST sayesinde post otomatik kaydedilir
        return post;
    }

    public void deletePost(Long authorId, Long postId) {
        Author author = authorRepository.findById(authorId)
            .orElseThrow(() -> new ResourceNotFoundException("Author not found"));

        Post post = postRepository.findById(postId)
            .orElseThrow(() -> new ResourceNotFoundException("Post not found"));

        // orphanRemoval = true sayesinde post veritabanından silinir
        author.removePost(post);
    }
}

Yaygın Hatalar ve Çözümler

1. Sadece Inverse Side'ı Güncellemek

// ❌ Bu kod veritabanına hiçbir şey yazmaz!
Department dept = departmentRepo.findById(1L).orElseThrow();
Employee emp = new Employee("Ali");
dept.getEmployees().add(emp);  // Inverse side — JPA bunu yok sayar!
departmentRepo.save(dept);     // emp kaydedilmez (CascadeType yoksa)

// ✅ Owner side'ı güncelle
Employee emp = new Employee("Ali");
emp.setDepartment(dept);  // Owner side
employeeRepo.save(emp);   // FK doğru şekilde set edilir

2. toString() ve Sonsuz Döngü

// ❌ Sonsuz döngü: Department.toString() → Employee.toString() → Department.toString() → ...
@Entity
public class Department {
    @Override
    public String toString() {
        return "Department{id=" + id + ", employees=" + employees + "}";
        // employees.toString() → her employee.toString() → department.toString() → 💥
    }
}

// ✅ İlişkili entity'leri toString'den çıkarın
@Override
public String toString() {
    return "Department{id=" + id + ", name=" + name + "}";
}

Aynı sorun JSON serialization'da da yaşanır. @JsonManagedReference / @JsonBackReference veya DTO pattern kullanın:

@Entity
public class Department {
    @JsonManagedReference
    @OneToMany(mappedBy = "department")
    private List<Employee> employees;
}

@Entity
public class Employee {
    @JsonBackReference
    @ManyToOne(fetch = FetchType.LAZY)
    private Department department;
}

3. equals/hashCode ile Koleksiyon Sorunları

// ❌ Auto-generated ID ile equals/hashCode
@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof Employee e)) return false;
    return Objects.equals(id, e.id); // id persist öncesinde null!
}

// ✅ Business key veya natural key kullanın
@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof Employee e)) return false;
    return Objects.equals(email, e.email); // email unique ve değişmez
}

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

4. LazyInitializationException

// ❌ Transaction dışında lazy koleksiyona erişim
@GetMapping("/departments/{id}")
public Department getDepartment(@PathVariable Long id) {
    Department dept = departmentRepo.findById(id).orElseThrow();
    dept.getEmployees().size(); // 💥 LazyInitializationException!
    return dept;
}

// ✅ JOIN FETCH veya @EntityGraph kullanın
@Query("SELECT d FROM Department d LEFT JOIN FETCH d.employees WHERE d.id = :id")
Optional<Department> findByIdWithEmployees(@Param("id") Long id);

@OrderBy ve @OrderColumn

Koleksiyondaki elemanların sırasını kontrol etmek için:

// Veritabanı sıralama — sorguya ORDER BY eklenir
@OneToMany(mappedBy = "department")
@OrderBy("name ASC, email DESC")
private List<Employee> employees;

// Kalıcı sıralama — ayrı bir index sütunu oluşturur
@OneToMany(mappedBy = "department")
@OrderColumn(name = "employee_order")
private List<Employee> employees;
// → employee tablosuna employee_order sütunu eklenir

@OrderColumn kullanırken dikkat: eleman eklendiğinde veya çıkarıldığında tüm index'ler güncellenir — büyük listelerde performans sorunu yaratabilir.


Performans İpuçları

  1. Her zaman `FetchType.LAZY` kullanın@ManyToOne default'u EAGER'dır, değiştirin

  2. Helper metodlar yazın — senkronizasyon hatalarını önler

  3. Unidirectional `@ManyToOne` ile başlayın — bidirectional gerçekten gerekli mi?

  4. `Set` yerine `List` kullanın — sıralama gerekiyorsa @OrderBy ekleyin

  5. Cascade'i dar tutunCascadeType.ALL yerine PERSIST + MERGE genellikle yeterli

  6. N+1 problemine dikkatJOIN FETCH veya @EntityGraph kullanın

  7. DTO projection — Entity döndürmek yerine sadece gerekli alanları çekin


Özet

  • @ManyToOne → "çok" tarafına (owner side, FK'yı taşır), @OneToMany → "bir" tarafına (inverse side, mappedBy kullanır)

  • Owner side ilişkiyi yönetir — JPA sadece owner side'daki değişiklikleri veritabanına yansıtır

  • mappedBy Java field adıdır, veritabanı sütun adı değil — yanlış yazarsanız çalışma zamanı hatası alırsınız

  • Helper metodlar (addX/removeX) her iki tarafı senkronize eder — bidirectional ilişkilerde zorunludur

  • Unidirectional `@ManyToOne` en basit ve performanslı seçenektir — gerçekten gerekmedikçe bidirectional yapmayın

  • FetchType.LAZY her zaman tercih edilmelidir — @ManyToOne default'u EAGER olduğu için açıkça belirtin