Cascade Types and Orphan Removal: Managing Lifecycle Propagation

Introduction

Cascade types control which persistence operations (save, merge, delete, refresh) propagate from a parent entity to its associated children. orphanRemoval controls what happens when a child is removed from the parent’s collection. Getting cascade wrong is one of the most common causes of unexpected deletes and data integrity issues in JPA applications.


The Six Cascade Types

cascade = CascadeType.PERSIST   // propagate persist (save new entity)
cascade = CascadeType.MERGE     // propagate merge (re-attach detached entity)
cascade = CascadeType.REMOVE    // propagate remove (delete)
cascade = CascadeType.REFRESH   // propagate refresh (reload from DB)
cascade = CascadeType.DETACH    // propagate detach (remove from context)
cascade = CascadeType.ALL       // all of the above

CascadeType.PERSIST

When you save the parent, unsaved (transient) children are also saved.

@OneToMany(mappedBy = "order", cascade = CascadeType.PERSIST)
private List<OrderItem> items = new ArrayList<>();
Order order = new Order();       // transient
OrderItem item = new OrderItem(); // transient
order.addItem(item);

orderRepository.save(order);
// CascadeType.PERSIST: saves item too
// Without PERSIST: item stays transient → NOT saved → item.orderId is null

Without PERSIST, you would need to call itemRepository.save(item) separately for every item.


CascadeType.MERGE

When you merge a detached parent back into the persistence context, detached children are also merged.

@OneToMany(mappedBy = "order", cascade = {CascadeType.PERSIST, CascadeType.MERGE})
private List<OrderItem> items = new ArrayList<>();
// order is detached (loaded in a previous transaction)
order.getItems().get(0).setQuantity(3);  // change on detached child

orderRepository.save(order);  // save() calls merge() for detached entities
// CascadeType.MERGE: also merges modified items

Without MERGE, changes to detached children would be lost — only the parent would be merged.


CascadeType.REMOVE

When you delete the parent, children are also deleted.

@OneToMany(mappedBy = "order", cascade = CascadeType.REMOVE)
private List<OrderItem> items = new ArrayList<>();
orderRepository.delete(order);
// CascadeType.REMOVE: deletes all order items first, then the order

Generated SQL:

DELETE FROM order_items WHERE order_id = ?   -- items deleted first
DELETE FROM orders WHERE id = ?              -- then the order

The danger of CascadeType.REMOVE on shared entities

Never use REMOVE on a @ManyToMany or @ManyToOne to a shared entity:

// DANGEROUS — deletes the Category when any Product is deleted
@ManyToOne(cascade = CascadeType.REMOVE)
private Category category;

If you delete a Product, it cascades to delete the Category — which deletes all other products in that category. This is almost never the intent.

Rule: Use CascadeType.REMOVE only for privately owned entities — children that cannot exist without their parent (OrderItem → Order, CustomerProfile → Customer).


CascadeType.ALL

ALL is shorthand for all five cascade types. Use it when the child entity is fully owned by the parent and has no meaning outside it:

@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();

@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@JoinColumn(name = "profile_id")
private CustomerProfile profile;

Entities appropriate for CascadeType.ALL:

  • OrderItem (owned by Order)
  • CustomerProfile (owned by Customer)
  • Address embedded in Order (but better as @Embeddable)

Entities NOT appropriate for CascadeType.ALL:

  • Category referenced by Product
  • Tag referenced by Product
  • Customer referenced by Order

orphanRemoval

orphanRemoval = true automatically deletes a child entity when it is removed from the parent’s collection or when its parent reference is set to null.

@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
@Transactional
public void removeFirstItem(Long orderId) {
    Order order = orderRepository.findById(orderId).orElseThrow();
    OrderItem firstItem = order.getItems().get(0);

    order.removeItem(firstItem);
    // orphanRemoval = true: the item row is deleted from order_items on flush
    // No explicit itemRepository.delete() needed
}

orphanRemoval vs CascadeType.REMOVE

CascadeType.REMOVEorphanRemoval = true
When triggeredWhen parent entity is deletedWhen child is removed from collection OR parent reference set to null
Use caseDelete children when parent is deletedAlso delete children when they are disowned
Typical combocascade = CascadeType.ALL already includes REMOVEUsed alongside cascade = CascadeType.ALL

orphanRemoval = true implies CascadeType.REMOVE. It adds the extra behaviour of deleting when removed from the collection.

Most @OneToMany owned relationships should have both:

@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)

Cascade in Action: Full Order Lifecycle

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;

    // Create — PERSIST cascades to items
    @Transactional
    public Order create(Customer customer, List<OrderItemRequest> requests) {
        Order order = new Order();
        order.setCustomer(customer);
        order.setOrderedAt(LocalDateTime.now());

        for (OrderItemRequest req : requests) {
            OrderItem item = new OrderItem();
            item.setProduct(req.product());
            item.setQuantity(req.quantity());
            item.setUnitPrice(req.product().getPrice());
            order.addItem(item);
        }

        return orderRepository.save(order);
        // PERSIST cascades: saves order + all items
    }

    // Update — MERGE cascades to items
    @Transactional
    public Order updateQuantity(Long orderId, Long itemId, int newQty) {
        Order order = orderRepository.findById(orderId).orElseThrow();
        order.getItems().stream()
            .filter(i -> i.getId().equals(itemId))
            .findFirst()
            .ifPresent(i -> i.setQuantity(newQty));
        // dirty checking detects the item change — no explicit save needed
        return order;
    }

    // Remove item — orphanRemoval deletes it
    @Transactional
    public void removeItem(Long orderId, Long itemId) {
        Order order = orderRepository.findById(orderId).orElseThrow();
        order.getItems().removeIf(i -> i.getId().equals(itemId));
        // orphanRemoval = true: DELETE FROM order_items WHERE id = ?
    }

    // Delete order — REMOVE cascades to items
    @Transactional
    public void cancel(Long orderId) {
        orderRepository.deleteById(orderId);
        // CascadeType.REMOVE: DELETE FROM order_items WHERE order_id = ?
        //                     DELETE FROM orders WHERE id = ?
    }
}

Cascade and Performance

Cascading REMOVE on a large collection loads all children into memory first:

orderRepository.delete(order);
// Hibernate: SELECT * FROM order_items WHERE order_id = ?   ← loads all items
// Then: DELETE FROM order_items WHERE id = ?  (one per item)
// Then: DELETE FROM orders WHERE id = ?

For bulk deletes, use a @Modifying query instead of cascade:

@Modifying
@Query("DELETE FROM OrderItem i WHERE i.order.id = :orderId")
void deleteByOrderId(@Param("orderId") Long orderId);

Then:

itemRepository.deleteByOrderId(orderId);  // bulk delete in one SQL
orderRepository.deleteById(orderId);

Cascade Reference Summary

Relationship typeRecommended cascadeorphanRemoval
Parent → owned children (Order → OrderItem)CascadeType.ALLtrue
Parent → exclusive component (Customer → Profile)CascadeType.ALLtrue (if @OneToOne)
Entity → shared reference (Product → Category)NoneN/A
Many-to-Many to shared entity (Product → Tag){PERSIST, MERGE} at mostN/A
Entity → parent (OrderItem → Order)NoneN/A

Key Takeaways

  • CascadeType.PERSIST propagates save to transient children — prevents TransientPropertyValueException
  • CascadeType.MERGE propagates merge to detached children — ensures detached child changes are saved
  • CascadeType.REMOVE propagates delete — only use for privately owned children
  • CascadeType.ALL is appropriate for fully owned entities (OrderItem, CustomerProfile) — never for shared references
  • orphanRemoval = true deletes a child when removed from the collection OR when its parent reference is set to null
  • For bulk deletes, use @Modifying @Query instead of cascade — cascade loads all children into memory first

What’s Next

Article 13 covers fetch types in depth — EAGER vs LAZY, the default fetch type for each relationship annotation, the LazyInitializationException, and all the strategies for loading lazy associations safely.