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 byOrder)CustomerProfile(owned byCustomer)Addressembedded inOrder(but better as@Embeddable)
Entities NOT appropriate for CascadeType.ALL:
Categoryreferenced byProductTagreferenced byProductCustomerreferenced byOrder
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.REMOVE | orphanRemoval = true | |
|---|---|---|
| When triggered | When parent entity is deleted | When child is removed from collection OR parent reference set to null |
| Use case | Delete children when parent is deleted | Also delete children when they are disowned |
| Typical combo | cascade = CascadeType.ALL already includes REMOVE | Used 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 type | Recommended cascade | orphanRemoval |
|---|---|---|
| Parent → owned children (Order → OrderItem) | CascadeType.ALL | true |
| Parent → exclusive component (Customer → Profile) | CascadeType.ALL | true (if @OneToOne) |
| Entity → shared reference (Product → Category) | None | N/A |
| Many-to-Many to shared entity (Product → Tag) | {PERSIST, MERGE} at most | N/A |
| Entity → parent (OrderItem → Order) | None | N/A |
Key Takeaways
CascadeType.PERSISTpropagates save to transient children — preventsTransientPropertyValueExceptionCascadeType.MERGEpropagates merge to detached children — ensures detached child changes are savedCascadeType.REMOVEpropagates delete — only use for privately owned childrenCascadeType.ALLis appropriate for fully owned entities (OrderItem, CustomerProfile) — never for shared referencesorphanRemoval = truedeletes a child when removed from the collection OR when its parent reference is set to null- For bulk deletes, use
@Modifying @Queryinstead 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.