← Kursa Dön
📄 Text · 15 min

Cascade & orphanRemoval Detaylı

Giriş — Neden Bu Konu Önemli?

Bir sipariş ve onun kalemleri (order items) arasındaki ilişkiyi düşünün. Sipariş kaydedildiğinde kalemleri de kaydedilmeli, sipariş silindiğinde kalemleri de silinmeli, bir kalem siparişten çıkarıldığında veritabanından da kalkmalı. Bu davranışları elle yönetmek hem hataya açık hem de kod tekrarına yol açar.

JPA'da cascade (zincirleme) ve orphanRemoval (yetim kaldırma) mekanizmaları, parent-child ilişkilerinde entity yaşam döngüsünü yönetmenin temel araçlarıdır. Bu mekanizmaları yanlış kullanmak veri kaybına veya performans sorunlarına yol açabilir — bu yüzden her bir cascade tipini derinlemesine anlamak kritiktir.

Gerçek Hayat Analojisi: Bir şirket ve çalışanları düşünün. Şirket iflas ettiğinde (REMOVE) çalışanlar da işsiz kalır — bu cascade. Bir çalışan departmandan çıkarıldığında (koleksiyondan kaldırıldığında) eğer başka yerde çalışmıyorsa kontratı feshedilir — bu orphanRemoval. Cascade operasyonun yukarıdan aşağı yayılması, orphanRemoval ise "sahipsiz kalan" entity'nin temizlenmesidir.


CascadeType Türleri

JPA altı cascade tipi tanımlar. Her biri, parent entity üzerindeki bir operasyonun child entity'lere yayılmasını kontrol eder:

CascadeType.PERSIST

Parent entity persist() edildiğinde, child entity'ler de otomatik olarak persist edilir:

@OneToMany(mappedBy = "author", cascade = CascadeType.PERSIST)
private List<Book> books;

// Kullanım:
Author author = new Author("Orhan Pamuk");
Book book1 = new Book("Kar");
Book book2 = new Book("İstanbul");
author.addBook(book1);
author.addBook(book2);

entityManager.persist(author);
// → 1 INSERT author + 2 INSERT book = 3 SQL statement
// author VE tüm book'lar veritabanına kaydedilir

PERSIST olmadan, her child entity'yi ayrı ayrı persist() etmeniz gerekirdi:

// ❌ Cascade PERSIST olmadan — her birini ayrı kaydet
entityManager.persist(author);
entityManager.persist(book1);  // Unutursanız kaybolur!
entityManager.persist(book2);

💡 İpucu: Spring Data JPA'da repository.save() kullandığınızda, entity yeniyse persist(), mevcutsa merge() çağrılır. Cascade.PERSIST yeni entity'lerin otomatik kaydedilmesini sağlar.

CascadeType.MERGE

Parent entity merge() edildiğinde, child entity'ler de merge edilir. Bu, detached (oturumdan kopmuş) entity'leri güncellerken önemlidir:

@OneToMany(mappedBy = "author", cascade = CascadeType.MERGE)
private List<Book> books;

// Detached author objesini güncelleme
author.setName("Orhan Pamuk - Güncellendi");
author.getBooks().get(0).setTitle("Kar - Yeni Baskı");

entityManager.merge(author);
// → UPDATE author SET name = ... WHERE id = ?
// → UPDATE book SET title = ... WHERE id = ?
// author VE book güncellenir

MERGE olmadan, detached parent'ı merge ettiğinizde child'lardaki değişiklikler kaybolur.

CascadeType.REMOVE

Parent entity silindiğinde, child entity'ler de silinir:

@OneToMany(mappedBy = "author", cascade = CascadeType.REMOVE)
private List<Book> books;

entityManager.remove(author);
// → Hibernate şu sırayla çalışır:
// 1. DELETE FROM book WHERE author_id = ? (tüm kitaplar)
// 2. DELETE FROM author WHERE id = ?
// Önce child'lar silinir, sonra parent — FK constraint ihlali olmaz

⚠️ Dikkat: CascadeType.REMOVE, child entity'lerin başka parent'larla da ilişkisi varsa tehlikelidir! Örneğin bir Book birden fazla Author ile ilişkiliyse (@ManyToMany), bir author silindiğinde book da silinir ve diğer author'lar bozulur. REMOVE cascade'ini sadece gerçek parent-child (sahiplik) ilişkilerinde kullanın.

CascadeType.REFRESH

Parent entity refresh() edildiğinde, child'lar da veritabanından yeniden yüklenir:

@OneToMany(mappedBy = "department", cascade = CascadeType.REFRESH)
private List<Employee> employees;

// Senaryo: Başka bir thread veritabanını güncelledi
entityManager.refresh(department);
// → SELECT * FROM department WHERE id = ?
// → SELECT * FROM employee WHERE department_id = ?
// department VE employees veritabanından yeniden okunur
// In-memory değişiklikler atılır!

REFRESH nadiren kullanılır, ancak concurrent erişim durumlarında veritabanındaki güncel veriyi almak için faydalıdır.

CascadeType.DETACH

Parent entity detach edildiğinde, child'lar da persistence context'ten çıkarılır:

@OneToMany(mappedBy = "department", cascade = CascadeType.DETACH)
private List<Employee> employees;

entityManager.detach(department);
// → department VE employees artık managed değil
// Yapılan değişiklikler veritabanına yansımaz

CascadeType.ALL

Tüm cascade tiplerini birden aktif eder: PERSIST + MERGE + REMOVE + REFRESH + DETACH.

@OneToMany(mappedBy = "author", cascade = CascadeType.ALL)
private List<Book> books;

ALL kullanmak basit ve pratiktir, ancak gerçekten tüm operasyonların yayılmasını istiyor musunuz? Çoğu durumda PERSIST + MERGE yeterlidir. REMOVE cascade'i dikkatli değerlendirilmelidir.


orphanRemoval = true

orphanRemoval, cascade'den farklı bir mekanizmadır. Cascade, parent entity üzerindeki operasyonları child'lara yayarken, orphanRemoval koleksiyondan çıkarılan child'ları siler:

@OneToMany(mappedBy = "author", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Book> books;

// Senaryo 1: Bir kitabı koleksiyondan çıkarmak
author.getBooks().remove(book);
// → DELETE FROM book WHERE id = ?
// Book veritabanından SİLİNİR!

// Senaryo 2: Koleksiyonu tamamen temizlemek
author.getBooks().clear();
// → DELETE FROM book WHERE id = ? (her kitap için)
// Tüm kitaplar silinir!

// Senaryo 3: Yeni koleksiyon atamak
author.setBooks(newBookList);
// → Eski listedeki kitaplar silinir, yeni listedekiler eklenir

orphanRemoval = false (Default)

orphanRemoval = false durumunda, koleksiyondan çıkarılan child silinmez — sadece foreign key NULL yapılır:

@OneToMany(mappedBy = "author", orphanRemoval = false)  // default
private List<Book> books;

author.getBooks().remove(book);
// → UPDATE book SET author_id = NULL WHERE id = ?
// Book veritabanında KALIR, sadece author ilişkisi kopar!

Cascade vs orphanRemoval Karşılaştırma

Bu iki mekanizma farklı zamanlarda tetiklenir:

SenaryoCascadeType.REMOVEorphanRemoval = true
Parent silindiğinde✅ Child'lar silinir✅ Child'lar silinir
Child koleksiyondan çıkarıldığında❌ Child silinmez (FK=NULL)✅ Child silinir
Koleksiyon clear() yapıldığında❌ Child'lar silinmez✅ Tüm child'lar silinir
Yeni koleksiyon atandığında❌ Eski child'lar kalır✅ Eski child'lar silinir

Analoji: CascadeType.REMOVE, evin yıkılmasıyla içindekilerin de yok olmasıdır. orphanRemoval ise bir eşyanın evden atıldığında çöpe gitmesidir. Ev yıkılınca ikisi de aynı sonucu verir, ama "eşyayı evden çıkarmak" durumunda davranış farklıdır.


Cascade Yönü

Cascade, annotation'ın tanımlandığı taraftan karşı tarafa doğru çalışır:

// Parent → Child yönünde cascade (DOĞRU)
@Entity
public class Order {
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> items;
    // Order persist → OrderItem'lar da persist
    // Order remove → OrderItem'lar da remove
}

// Child → Parent yönünde cascade (TEHLİKELİ!)
@Entity
public class OrderItem {
    @ManyToOne(cascade = CascadeType.REMOVE)  // ❌ ASLA!
    private Order order;
    // OrderItem silindiğinde Order da silinir!
    // Diğer OrderItem'lar da cascade ile silinir!
}

⚠️ Altın Kural: Cascade her zaman parent → child yönünde tanımlanır. Child → parent yönünde cascade kullanmak, beklenmeyen toplu silmelere yol açar.


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

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

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

    @Enumerated(EnumType.STRING)
    private OrderStatus status = OrderStatus.PENDING;

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "customer_id", nullable = false)
    private Customer customer;

    // ✅ Güvenli cascade — sadece PERSIST ve MERGE
    // orphanRemoval — kalemden çıkarılan ürün silinir
    @OneToMany(
        mappedBy = "order",
        cascade = {CascadeType.PERSIST, CascadeType.MERGE},
        orphanRemoval = true
    )
    private List<OrderItem> items = new ArrayList<>();

    // --- Helper Methods ---
    public void addItem(Product product, int quantity, BigDecimal unitPrice) {
        OrderItem item = new OrderItem(this, product, quantity, unitPrice);
        items.add(item);
    }

    public void removeItem(OrderItem item) {
        items.remove(item);
        item.setOrder(null);
        // orphanRemoval = true → item veritabanından silinir
    }

    public BigDecimal getTotalAmount() {
        return items.stream()
            .map(OrderItem::getSubtotal)
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }

    public void clearItems() {
        items.clear();
        // orphanRemoval = true → tüm item'lar silinir
    }
}

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id", nullable = false)
    private Order order;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "product_id", nullable = false)
    private Product product;

    private int quantity;

    @Column(name = "unit_price", nullable = false)
    private BigDecimal unitPrice;

    protected OrderItem() {}

    public OrderItem(Order order, Product product, int quantity, BigDecimal unitPrice) {
        this.order = order;
        this.product = product;
        this.quantity = quantity;
        this.unitPrice = unitPrice;
    }

    public BigDecimal getSubtotal() {
        return unitPrice.multiply(BigDecimal.valueOf(quantity));
    }
}

public enum OrderStatus {
    PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED
}

Service Katmanı ile Kullanım

@Service
@Transactional
public class OrderService {

    private final OrderRepository orderRepository;
    private final ProductRepository productRepository;

    public Order createOrder(Long customerId, List<OrderItemRequest> itemRequests) {
        Customer customer = customerRepo.findById(customerId)
            .orElseThrow(() -> new ResourceNotFoundException("Customer not found"));

        Order order = new Order();
        order.setOrderNumber(generateOrderNumber());
        order.setCustomer(customer);

        for (OrderItemRequest req : itemRequests) {
            Product product = productRepository.findById(req.productId())
                .orElseThrow(() -> new ResourceNotFoundException("Product not found"));

            order.addItem(product, req.quantity(), product.getPrice());
            // CascadeType.PERSIST → OrderItem otomatik persist edilir
        }

        return orderRepository.save(order);
        // 1 INSERT order + N INSERT order_item
    }

    public Order updateOrderItems(Long orderId, List<OrderItemRequest> newItems) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new ResourceNotFoundException("Order not found"));

        // orphanRemoval = true → eski item'lar silinir
        order.clearItems();

        for (OrderItemRequest req : newItems) {
            Product product = productRepository.findById(req.productId())
                .orElseThrow();
            order.addItem(product, req.quantity(), product.getPrice());
        }

        return orderRepository.save(order);
        // DELETE eski item'lar + INSERT yeni item'lar
    }

    public void removeItemFromOrder(Long orderId, Long itemId) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow();

        OrderItem item = order.getItems().stream()
            .filter(i -> i.getId().equals(itemId))
            .findFirst()
            .orElseThrow(() -> new ResourceNotFoundException("Item not found"));

        order.removeItem(item);
        // orphanRemoval = true → DELETE FROM order_items WHERE id = ?
    }
}

Best Practices ve Anti-Pattern'ler

1. CascadeType.ALL Yerine Spesifik Cascade

// ❌ Genellikle fazla geniş
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> items;

// ✅ Sadece ihtiyacınız olanları belirtin
@OneToMany(
    mappedBy = "order",
    cascade = {CascadeType.PERSIST, CascadeType.MERGE},
    orphanRemoval = true
)
private List<OrderItem> items;

2. @ManyToMany'de REMOVE Kullanmayın

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

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

3. orphanRemoval Sadece Aggregate Root'ta

orphanRemoval, sadece aggregate root pattern'de kullanılmalıdır — yani child entity'ler sadece parent üzerinden erişiliyorsa ve bağımsız varlıkları yoksa:

// ✅ Uygun — OrderItem bağımsız var olamaz, sadece Order üzerinden erişilir
@OneToMany(mappedBy = "order", orphanRemoval = true)
private List<OrderItem> items;

// ❌ Uygunsuz — Employee bağımsız bir varlıktır, departmandan çıksa da var olmalı
@OneToMany(mappedBy = "department", orphanRemoval = true)
private List<Employee> employees;
// Departmandan çıkarılan çalışan silinmemeli, sadece departmansız kalmalı!

4. Cascade ve Performans

Cascade operasyonları entity bazlı çalışır — yani Hibernate her child için ayrı SQL statement oluşturur:

// 100 OrderItem olan bir Order silindiğinde:
// CascadeType.REMOVE ile:
// → 100 adet DELETE FROM order_items WHERE id = ?
// → 1 adet DELETE FROM orders WHERE id = ?
// Toplam: 101 SQL statement!

// Daha performanslı alternatif — bulk delete:
@Modifying
@Query("DELETE FROM OrderItem oi WHERE oi.order.id = :orderId")
void deleteByOrderId(@Param("orderId") Long orderId);

// Sonra order'ı sil
orderRepository.deleteById(orderId);
// Toplam: 2 SQL statement!

Büyük koleksiyonlarda cascade yerine bulk delete kullanmak dramatik performans farkı yaratır.

5. Cascade Debugging

Cascade sorunlarını debug etmek için Hibernate SQL loglarını açın:

# application.yml
logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.type.descriptor.sql.BasicBinder: TRACE

Bu ayarlar, her SQL statement'ı ve parametre değerlerini loglara yazar. Cascade'in ne zaman tetiklendiğini görmek için invaluable'dır.


Cascade ile Test Yazma

Cascade davranışlarını test etmek önemlidir — beklenmeyen silmeler veya persist'ler üretim ortamında veri kaybına yol açabilir:

@DataJpaTest
class CascadeTest {

    @Autowired
    private TestEntityManager em;

    @Test
    void shouldCascadePersistOrderItems() {
        Order order = new Order();
        order.setOrderNumber("ORD-001");

        Product product = em.persist(new Product("Laptop", new BigDecimal("999.99")));
        order.addItem(product, 2, product.getPrice());

        em.persistAndFlush(order);
        em.clear();  // First-level cache temizle

        Order found = em.find(Order.class, order.getId());
        assertNotNull(found);
        assertEquals(1, found.getItems().size());
        assertEquals("Laptop", found.getItems().get(0).getProduct().getName());
    }

    @Test
    void shouldOrphanRemoveWhenItemRemoved() {
        // Setup
        Order order = createOrderWithItems(3);
        em.persistAndFlush(order);
        Long removedItemId = order.getItems().get(0).getId();

        // Act
        order.removeItem(order.getItems().get(0));
        em.flush();
        em.clear();

        // Assert
        assertNull(em.find(OrderItem.class, removedItemId));  // Silinmeli!
        Order found = em.find(Order.class, order.getId());
        assertEquals(2, found.getItems().size());
    }

    @Test
    void shouldOrphanRemoveAllWhenCleared() {
        Order order = createOrderWithItems(5);
        em.persistAndFlush(order);
        List<Long> itemIds = order.getItems().stream()
            .map(OrderItem::getId).toList();

        order.clearItems();
        em.flush();
        em.clear();

        for (Long itemId : itemIds) {
            assertNull(em.find(OrderItem.class, itemId));  // Hepsi silinmeli!
        }
    }

    @Test
    void shouldNotCascadeRemoveWhenNotConfigured() {
        // cascade'de REMOVE yok, sadece PERSIST + MERGE
        Department dept = new Department("Engineering");
        Employee emp = new Employee("Ali");
        dept.addEmployee(emp);
        em.persistAndFlush(dept);
        Long empId = emp.getId();

        // Department silindiğinde employee silinmemeli
        // (cascade REMOVE olmadığı için FK constraint hatası verebilir
        //  — bu durumda önce employee'yi detach etmek gerekir)
    }
}

Cascade Karar Ağacı

Hangi cascade tipini kullanacağınıza karar verirken şu soruları sorun:

  1. Child entity, parent olmadan var olabilir mi?

- Evet → cascade REMOVE kullanma, orphanRemoval kullanma - Hayır → cascade REMOVE ve/veya orphanRemoval uygun

  1. Child entity başka parent'larla paylaşılıyor mu?

- Evet → cascade REMOVE asla kullanma (@ManyToMany durumu) - Hayır → cascade REMOVE güvenle kullanılabilir

  1. Koleksiyondan çıkarılan child silinmeli mi?

- Evet → orphanRemoval = true - Hayır → orphanRemoval = false (default)

  1. Parent kaydedilirken child'lar da otomatik kaydedilsin mi?

- Evet → cascade PERSIST ekle - Hayır → child'ları ayrı persist et

  1. Performans kritik mi ve koleksiyon büyük mü?

- Evet → cascade yerine bulk operasyonlar kullan - Hayır → cascade yeterli


Özet

  • CascadeType.PERSIST: Parent kaydedildiğinde child'lar da kaydedilir — yeni entity'ler için

  • CascadeType.MERGE: Parent güncellendiğinde child'lar da güncellenir — detached entity'ler için

  • CascadeType.REMOVE: Parent silindiğinde child'lar da silinir — sadece aggregate ilişkilerde kullanın

  • orphanRemoval = true: Koleksiyondan çıkarılan child veritabanından silinir — aggregate root pattern'de

  • CascadeType.ALL dikkatli kullanın — çoğunlukla PERSIST + MERGE yeterlidir

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

  • Cascade yönü parent → child olmalıdır — child → parent cascade tehlikelidir

  • Büyük koleksiyonlarda bulk delete cascade'den çok daha performanslıdır