CodeGym /Cursos /Hibernate deep-dive /orphanRemoval y la ruptura de la relación: DELETE

orphanRemoval y la ruptura de la relación: DELETE

Hibernate deep-dive
Nivel 11, Lección 3
Disponible

1. remove() ≠ DELETE en la BD

Imaginad que gestionáis un pedido (PurchaseOrder) y sus líneas (OrderItem). En Java todo parece amigable: existe order.getItems() y se puede hacer remove(). Parece lógico que la línea desaparezca «en todas partes». Pero Hibernate no piensa con emociones, sino con claves externas, el ciclo de flush() y las reglas de la relación. Como resultado, en memoria la línea ha desaparecido, pero en la BD la fila permanece — y obtenéis datos «huérfanos» o errores de restricciones.

Dibujemos un escenario típico de nuestro Commerce Persistence Lab.

Tenemos una relación:

- PurchaseOrder contiene una colección items
- OrderItem contiene una referencia order (y aquí, como ya sabemos, suele vivir la clave externa order_id)

El código ingenuo se ve así:

import jakarta.persistence.EntityManager;

PurchaseOrder order = em.find(PurchaseOrder.class, 1L);
OrderItem item = em.find(OrderItem.class, 10L);

// Eliminamos el elemento de la colección del padre (en memoria «ha desaparecido»)
order.getItems().remove(item);

// Forzamos el envío de los cambios acumulados a la BD para ver el SQL real
em.flush();

Desde el punto de vista del desarrollador novel, esto es casi magia: «pero si lo he eliminado de la lista». Y Hibernate responde: «has eliminado de la colección, que no gestiona la clave externa (inverse side), y en general no está claro qué quieres: desvincular la línea del pedido o eliminar la línea como entidad».

Y aquí surge la primera pregunta vital que en ORM debe hacerse más a menudo que «por qué no funciona»:

¿Puede una línea de pedido (OrderItem) vivir sin un pedido (PurchaseOrder)?

Si la respuesta es «no», entonces almacenar un OrderItem por separado en la tabla sin un pedido no tiene sentido. Esta es la situación clásica para orphanRemoval.

2. ¿Qué es orphanRemoval?

Cuando veis por primera vez la palabra orphanRemoval, la mente dibuja un drama: «huérfanos», «eliminación», «irreversible». Y esto… está bastante cerca de la verdad. orphanRemoval = true es una configuración del mapeo que le dice a Hibernate: si una child-entity ya no pertenece a la parent-entity (es decir, la relación se ha roto), entonces hay que eliminar la child (DELETE). No «desvincular», no «dejar para después», sino eliminar la fila de la BD.

En JPA se ve así: activáis orphanRemoval en @OneToMany (o @OneToOne) del lado del padre:

import jakarta.persistence.CascadeType;
import jakarta.persistence.OneToMany;

@OneToMany(
        mappedBy = "order", // inverse side: la clave externa NO vive aquí
        cascade = {CascadeType.PERSIST, CascadeType.REMOVE}, // se crea junto con el parent, se elimina junto con él
        orphanRemoval = true // «sin padre no hay hijo»: se rompe la relación => DELETE
)
private java.util.List<OrderItem> items;

Aquí hay dos ideas importantes.

Primera: orphanRemoval no es «eliminar con un botón». Es una regla del ciclo de vida. En cuanto un objeto se convierte en «huérfano» en términos de la relación, Hibernate en el flush() lo pasa al estado removed y realiza un DELETE.

Segunda: orphanRemoval no sustituye la comprensión del owning side. No anula el hecho de que la clave externa vive en el lado @ManyToOne. Simplemente añade una nueva regla: «ruptura de la relación = eliminación de la entidad».

Si queréis una analogía, aquí tenéis una muy doméstica: OrderItem en nuestro dominio no es un «mueble independiente», sino una «pata de una silla». Una pata puede existir físicamente por separado, pero en vuestro modelo de datos no tiene sentido. Por lo tanto, la regla «pata sin silla, a la basura» es precisamente orphanRemoval.

3. orphanRemoval y CascadeType.REMOVE

Cuando un desarrollador novato ve CascadeType.REMOVE y orphanRemoval, a menudo piensa: «ambos tratan sobre eliminación, así que son lo mismo». Este es exactamente el momento en que Hibernate hace las maletas y se va de vacaciones, dejándoos un ConstraintViolationException. Porque la eliminación es realmente una (DELETE), pero el cuándo debe ocurrir son dos mundos diferentes.

Comparemos de forma humana:

Mecanismo Cuándo se activa Qué hacéis en el código Qué se espera en SQL
CascadeType.REMOVE Cuando elimináis al padre entityManager.remove(order) DELETE del padre y (si la cascada está configurada) DELETE de los hijos
orphanRemoval = true Cuando rompéis la relación y el hijo ya no pertenece al padre order.removeItem(item) DELETE del hijo, incluso si el padre sigue vivo

Ejemplo para CascadeType.REMOVE (eliminamos el pedido completo):

import jakarta.persistence.EntityManager;

PurchaseOrder order = em.find(PurchaseOrder.class, 1L);

// El disparador aquí es: eliminamos la entidad parent
em.remove(order);

// El SQL se enviará en flush/commit: esperamos DELETE del pedido y de los hijos (si la cascada está configurada)
em.flush();

Ejemplo para orphanRemoval (el pedido permanece, eliminamos la línea como «parte del pedido»):

import jakarta.persistence.EntityManager;

PurchaseOrder order = em.find(PurchaseOrder.class, 1L);
OrderItem item = em.find(OrderItem.class, 10L);

// El disparador aquí es: ruptura de la relación (el hijo deja de pertenecer al padre)
order.removeItem(item);

// En flush/commit Hibernate eliminará al child como huérfano (DELETE de order_item)
em.flush();

Y esto es fundamentalmente importante en la lógica de negocio. El pedido puede permanecer, pero habéis eliminado la línea del pedido y debe desaparecer de la BD porque no tiene vida independiente.

4. Cómo funciona orphanRemoval

Para que orphanRemoval deje de parecer una «casilla mágica», es importante ver la mecánica. Hibernate no recorre los objetos inmediatamente después de remove() de la colección y no elimina la fila en ese mismo momento. Funciona en modo unit of work: los cambios se acumulan en el persistence context, y el SQL se envía en el flush() (explícito o automático). Por lo tanto, orphanRemoval también es una historia sobre el persistence context y el ciclo de flush, pero con énfasis en la eliminación.

Ocurre aproximadamente la siguiente cadena.

Durante la transacción, Hibernate mantiene en el persistence context los objetos managed y sus instantáneas. Las colecciones como order.items no son simplemente un «ArrayList», sino una persistent collection que puede recordar que se ha eliminado un elemento de ella. Cuando llamáis a un método helper y quitáis un OrderItem de items, Hibernate marca el cambio en la colección. Si orphanRemoval = true, entonces el «elemento eliminado de la colección» se convierte en candidato a ser eliminado como entidad.

Pero aquí hay un matiz que conecta este tema con las lecciones sobre owning side y métodos helper. Si habéis eliminado el elemento solo de la colección, pero habéis dejado en él item.setOrder(this), habéis creado una desincronización: el inverse side dice «ya no está en el pedido», el owning side dice «todavía pertenece al pedido». En tales situaciones, Hibernate puede comportarse de forma inesperada, y la depuración se convierte en chamanismo. Por ello, en una aplicación normal, realizáis la operación remove de forma coherente, actualizando ambos lados.

Aquí tenéis un mini-esquema (de forma muy simplificada) de lo que queremos obtener:

flowchart TD
  %% Ruptura de la relación en el modelo de objetos + flush => DELETE en la BD (con orphanRemoval = true)
  A["items.remove(item)"] --> B["item.setOrder(null)"]
  B --> C["flush()"]
  C --> D[Hibernate ve el huérfano]
  D --> E[DELETE de order_item]

Y prestad atención a la palabra clave: flush. Si no llegáis al flush (o al commit), solo veis cambios en memoria. Para diagnosticar orphan removal casi siempre es útil hacer entityManager.flush() explícitamente para «sacar el SQL a la luz» y dejar de adivinar.

5. Práctica: eliminamos un OrderItem mediante orphanRemoval

Ahora vamos a reunir todo en un escenario muy concreto: el pedido sigue vivo, la línea se elimina, y queremos ver un DELETE real en el log de SQL. Hago los ejemplos deliberadamente pequeños y «directos», porque en esta fase del curso nos importa más la previsibilidad que la belleza de la arquitectura. La belleza llegará cuando tengáis una comprensión sólida del comportamiento del ORM (y un poco de paciencia).

Y ya en este punto es útil tener una instantánea de trabajo completa. OrderItem en nuestra variante es parte del pedido: se crea junto con el pedido, se elimina junto con el pedido, y al romperse la relación debe desaparecer de la BD. Por lo tanto, en la relación es suficiente con PERSIST y REMOVE; los cambios normales en los campos del hijo los capturará el dirty checking.

Mapeo: dónde activamos orphanRemoval

Necesitamos una relación bidireccional típica PurchaseOrder (1) -> (N) OrderItem. Del lado del pedido activamos orphan removal:

import jakarta.persistence.CascadeType;
import jakarta.persistence.OneToMany;

@OneToMany(
        mappedBy = "order", // inverse side, la FK estará en el lado de OrderItem
        cascade = {CascadeType.PERSIST, CascadeType.REMOVE}, // creamos las líneas junto con el pedido, las eliminamos junto con el pedido
        orphanRemoval = true // eliminado de la colección => eliminar la fila en la BD (en flush/commit)
)
private java.util.List<OrderItem> items = new java.util.ArrayList<>();

Y en el lado de la línea de pedido tenemos el owning side (@ManyToOne), a través del cual en la tabla order_item vive order_id:

import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;

@ManyToOne(optional = false) // en el dominio, una línea no debe existir sin un pedido
@JoinColumn(name = "order_id", nullable = false) // FK not null: no se puede «desvincular», solo eliminar
private PurchaseOrder order;

Aquí nullable = false es muy realista: una línea de pedido sin pedido no tiene sentido y no debería existir en la BD como una fila suelta «a ninguna parte».

Método helper: rompemos la relación correctamente

Ahora la parte clave: el método helper removeItem(). No debe ser simplemente «eliminar de la lista». Debe actualizar ambos lados para que el owning side también quede «vacío».

public void removeItem(OrderItem item) {
    // Lo quitamos de la colección del padre (inverse side)
    items.remove(item);

    // Actualizamos el owning side para que la referencia FK también se considere rota
    item.setOrder(null);
}

Sí, son solo dos líneas. Y sí, estas dos líneas os salvan de toda una clase de rarezas del ORM. Hibernate, aunque es inteligente, no lee vuestros pensamientos. Si en el grafo de objetos queda item.order = this, tiene derecho a pensar que «todavía no os habéis decidido».

En este modelo, OrderItem no se «muda» silenciosamente entre pedidos. Remove aquí significa exactamente romper la relación y permitir que Hibernate elimine al huérfano, no reasignar silenciosamente la línea a otro PurchaseOrder.

Escenario de servicio: eliminamos la línea y hacemos flush()

Creemos un pequeño método de servicio que trabaje dentro de una transacción, para que PurchaseOrder y OrderItem estén managed:

import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;

@Transactional
public void removeItemFromOrder(long orderId, long itemId, EntityManager em) {
    // Ambos objetos deben estar managed en el persistence context
    PurchaseOrder order = em.find(PurchaseOrder.class, orderId);
    OrderItem item = em.find(OrderItem.class, itemId);

    // Rompemos la relación «como parte del agregado»: con orphanRemoval esto significa eliminar al child
    order.removeItem(item);

    // «Linterna» para el laboratorio: ver el SQL ahora mismo, no esperar al commit
    em.flush();
}

He dejado flush() dentro del método a propósito. En código real a menudo confiáis en el flush en el commit, pero en un laboratorio educativo flush es una «linterna»: ilumina lo que realmente se enviará a la BD.

SQL esperado (simplificado, en cuanto al significado):

-- orphanRemoval = true: la ruptura de la relación lleva a la eliminación de la fila
delete from order_item where id = ?;

Si tenéis activado el perfil sql-trace, veréis también los parámetros bind. Y este es el momento en que la frase «lo eliminé de la colección» se vuelve verificable: eliminación de la colección + orphan removal = delete del hijo en el flush.

6. Sin orphanRemoval: desvincular ≠ eliminar

A veces el desarrollador dice: «no quiero orphanRemoval, quiero controlar la eliminación manualmente». Esto puede ser una decisión consciente. Pero es importante entender qué realidad estáis eligiendo. Sin orphan removal, la ruptura de la relación normalmente no significa un DELETE, sino un intento de poner la FK a NULL (si es que está permitido).

Supongamos que teníamos esto:

import jakarta.persistence.OneToMany;

@OneToMany(mappedBy = "order") // orphanRemoval desactivado: ruptura de la relación no implica DELETE
private java.util.List<OrderItem> items = new java.util.ArrayList<>();

Y llamáis:

// Rompemos la relación a nivel de objetos, pero sin orphanRemoval esto aún no es una «eliminación»
order.removeItem(item);

// En el flush, Hibernate intentará «conciliar» la columna FK en la BD
em.flush();

A partir de aquí son posibles dos mundos.

Si order_id es nullable, Hibernate puede hacer algo como esto:

-- La ruptura de la relación sin orphanRemoval a menudo se convierte en «poner a null la FK»
update order_item set order_id = null where id = ?;

Y entonces os queda una fila order_item que ya no está vinculada a nada. Eso es un «huérfano» a nivel de base de datos, pero en el mal sentido. En el dominio del pedido, una fila así normalmente no tiene sentido.

Si order_id es NOT NULL (y a menudo debería serlo), entonces el intento de «desvincular» la línea se convierte en un error de restricción. Solo queréis quitar la línea, y obtenéis una excepción que parece que habéis roto el mundo.

En este punto, los desarrolladores suelen hacer un «parche reflejo»: añaden en algún sitio repository.delete(item) o em.remove(item), pero se olvidan de actualizar la colección del pedido. Y entonces en memoria la colección sigue conteniendo el objeto eliminado. A Hibernate no le gusta esto, y a vuestro futuro compañero tampoco.

Es decir, sin orphanRemoval necesitáis mantener la disciplina manualmente: si la línea ya no es necesaria, debéis eliminar explícitamente la entidad y quitarla de la colección de forma coherente. orphanRemoval simplemente codifica esta disciplina en el mapeo para que no tengáis que repetir lo mismo en cada servicio.

7. ¿Dónde es apropiado orphanRemoval?

Ampliemos un poco la perspectiva: orphanRemoval no es una «funcionalidad para pedidos». Es una herramienta general para relaciones donde el child no debe tener vida independiente. En nuestro proyecto, otro candidato es Customer y sus direcciones CustomerAddress. Pero, como siempre en ORM, «se puede» no significa «es necesario». La elección depende de la regla de dominio.

Si la dirección es una entidad que solo existe como parte del cliente (por ejemplo, «dirección de envío del cliente n.º 123»), entonces orphanRemoval es lógico: una dirección sin cliente no tiene sentido, y al eliminar la dirección del perfil esperamos un DELETE de la fila correspondiente.

El mapeo podría verse así:

import jakarta.persistence.CascadeType;
import jakarta.persistence.OneToMany;

@OneToMany(
        mappedBy = "customer", // inverse side
        cascade = {CascadeType.PERSIST, CascadeType.REMOVE}, // las direcciones se crean junto con el cliente, se eliminan junto con él
        orphanRemoval = true // quitar la dirección de la colección del cliente => eliminar la fila de la dirección
)
private java.util.List<CustomerAddress> addresses = new java.util.ArrayList<>();

Pero si según vuestra lógica de negocio una dirección puede «sobrevivir» al traslado a otro cliente, o almacenáis direcciones como un catálogo, o necesitáis un historial de cambios de direcciones (no eliminar, sino «cerrar»), entonces orphanRemoval se vuelve peligroso. Porque «lo he quitado de la colección» significará «he eliminado la fila», no «lo he reasignado» o «lo he ocultado».

Por lo tanto, la regla de selección es muy simple (y muy práctica): si estáis dispuestos a decir «el child no puede ni debe existir sin el parent» — orphanRemoval es adecuado. Si dudáis — probablemente no sea adecuado. El ORM no tiene por qué adivinar vuestro dominio en este punto.

8. Errores típicos al usar orphanRemoval

Error n.º 1: eliminan al hijo solo de la colección y olvidan actualizar el owning side.
Desde fuera parece que «he quitado el item de order.getItems()», pero si no habéis puesto a null item.setOrder(null) (o no habéis actualizado el owning side de otro modo), creáis una desincronización en el modelo de objetos. En el mejor de los casos, Hibernate hará un SQL diferente al esperado. En el peor, obtendréis un estado difícil de explicar y aún más difícil de reproducir.

Error n.º 2: perciben orphanRemoval como un sustituto de CascadeType.REMOVE.
orphanRemoval no significa «elimina a los hijos cuando elimino al padre». Significa «elimina al hijo cuando ya no pertenece al padre». Son disparadores diferentes. Si elimináis un PurchaseOrder y la cascada REMOVE no está configurada (y no hay cascade a nivel de BD), las líneas pueden quedar y tendréis problemas con las FK. Eliminar al padre y romper la relación son eventos de negocio distintos.

Error n.º 3: esperan el DELETE «inmediatamente», no en el flush().
Mientras estáis en una transacción, sin flush(), los cambios viven en el persistence context. A menudo la gente mira el depurador, ve «en la colección no está el elemento» y piensa que la fila ya se ha eliminado. Pero el criterio real de verdad es el SQL en el flush/commit. Por lo tanto, para diagnosticar orphan removal casi siempre es útil hacer un em.flush() explícito en un escenario de laboratorio.

Error n.º 4: intentan «mover» al hijo a otro padre cuando orphanRemoval está activado.
Con orphanRemoval estáis diciendo efectivamente: «si el hijo se ha quitado del padre, elimínalo». Si en vuestro código hay un paso intermedio «quitar, luego poner en otro» dentro de la misma transacción, Hibernate puede alcanzar a programar la eliminación. Esto lleva a efectos muy extraños: parece que estáis moviendo el objeto, pero al final se elimina. Si necesitáis moverlo, es una señal de que orphanRemoval probablemente no refleja vuestro modelo.

Error n.º 5: activan orphanRemoval donde el hijo no es una «parte» del padre, sino una entidad compartida.
El ejemplo clásico de «mala idea» es poner orphan removal (o cascade remove) en un @ManyToOne hacia una entidad compartida, como Product. Se elimina una línea de pedido y el producto desaparece repentinamente del catálogo. No es un bug de Hibernate, es vuestra regla de ciclo de vida que habéis codificado accidentalmente en el mapeo. El ORM ha ejecutado obedientemente la orden, y luego tenéis que explicar al negocio por qué «los productos desaparecen solos».

1
Tarea
Hibernate deep-dive, nivel 11, lección 3
Bloqueada
Eliminación de una nota del bloc de notas mediante `orphanRemoval`
Eliminación de una nota del bloc de notas mediante `orphanRemoval`
1
Tarea
Hibernate deep-dive, nivel 11, lección 3
Bloqueada
Limpieza de pasos del plan de estudios sin `deleteAll` manual
Limpieza de pasos del plan de estudios sin `deleteAll` manual
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION