1. Comparativa con un único escenario
Ahora ya tenemos el cuadro completo: o bien traes al sistema el estado detached e intentas fusionarlo mediante merge(), o bien primero tomas el pedido managed y le aplicas el comando de cambio. Reduzcamos ambos enfoques al mismo escenario de edición en un backoffice — no por afán de elocuencia, sino por el comportamiento observable: qué objetos serán managed, dónde aparecerá el SELECT y qué exactamente decidirá actualizar Hibernate en el flush().
Imaginemos la pantalla «Edición de pedido». Un operador cambia el estado y ajusta la cantidad en varias líneas concretas. Es una operación típica de «modificación puntual del agregado», y es precisamente en esas actualizaciones parciales donde más frecuentemente aparece la magia del tipo «¿por qué actualizó lo que no debía?».
El servicio recibe no un snapshot completo de la entidad, sino un comando con lo que el usuario realmente cambió. Y aquí está la diferencia principal: merge() trata la entrada como fuente de estado para copiar, mientras que find() + mutate la trata como un conjunto de cambios concretos. Estos dos modelos suenan parecido hasta que se encuentran con una entrada incompleta.
2. Una única entrada para ambos caminos
Tomamos un mismo comando y lo procesamos de dos formas. En él no están todos los campos del pedido ni la lista completa de items — solo lo que el operador realmente modificó.
package com.example.commerce.orders.dto;
import java.util.List;
public record OrderEditRequest(
Long orderId, // id del pedido que se edita
String newStatus, // nuevo estado (p.ej., "PAID", "CANCELLED")
List<ItemQtyPatch> itemPatches // parches de líneas: cambiamos la cantidad solo de los itemId indicados
) {
public record ItemQtyPatch(
Long itemId, // id de la línea del pedido (OrderItem)
int newQty // nueva cantidad para esa línea
) {}
}
Este mismo request irá tanto a la rama con merge() como a la rama con find() + mutate. Nada de DTOs distintos para cada enfoque — de lo contrario la comparativa se convierte demasiado pronto en un cambio de términos.
3. Opción A: merge()
Flujo merge() — «copiamos el estado del objeto detached»
Empezamos con el flujo que trata la entrada como estado detached. El contrato básico lo tenemos claro: merge() copia el estado en la instancia managed y, si quieres seguir trabajando, debes usar el objeto devuelto. Pero lo más interesante ahora es otra cosa: qué ocurrirá si ese estado resulta ser un grafo parcial del pedido.
package com.example.commerce.orders.service;
import com.example.commerce.orders.dto.OrderEditRequest;
import com.example.commerce.orders.entity.OrderItem;
import com.example.commerce.orders.entity.OrderStatus;
import com.example.commerce.orders.entity.PurchaseOrder;
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderEditingMergeService {
private final EntityManager entityManager;
public OrderEditingMergeService(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional
public void editOrderWithMerge(OrderEditRequest request) {
PurchaseOrder detached = new PurchaseOrder();
detached.setId(request.orderId());
detached.setStatus(OrderStatus.valueOf(request.newStatus()));
detached.setItems(
request.itemPatches().stream()
.map(patch -> {
// La línea solo tiene id y qty: esto es un partial graph, no un snapshot completo del pedido
OrderItem item = new OrderItem();
item.setId(patch.itemId());
item.setQuantity(patch.newQty());
return item;
})
.toList()
);
entityManager.merge(detached);
entityManager.flush();
}
}
A nivel de Java todo parece compacto: construimos rápidamente un objeto del request y lo pasamos al ORM. Pero para Hibernate esto ya no es «cambia el estado y esas qty», sino «aquí tienes el estado del pedido y sus líneas». Y ese estado es incompleto: al pedido le faltan el resto de campos y a las líneas les falta el contexto completo de la relación y sus demás datos.
Por eso itemPatches aquí fácilmente deja de ser una lista de cambios puntuales y empieza a parecer la nueva versión de la colección items que hay que sincronizar.
SQL con merge()
Viendo el código, uno quiere creer que después vendrá «un UPDATE del pedido y un par de UPDATE de las líneas». En la práctica, merge() a menudo primero lee el pedido actual y su colección para obtener los representantes managed y entender qué exactamente sincronizar.
Por eso el perfil SQL típico del escenario de merge se parece a esto:
-- merge obtiene la raíz managed del pedido
select po.id, po.status
from purchase_order po
where po.id = ?;
-- y después se ocupa del contenido actual de la colección items
select oi.id, oi.order_id, oi.quantity
from order_item oi
where oi.order_id = ?;
-- solo después se envían los cambios
update purchase_order
set status = ?
where id = ?;
update order_item
set quantity = ?
where id = ?;
Y eso es el escenario optimista. Si el grafo detached resultó más pobre que el pedido real en BD, aquí se añaden fácilmente lecturas extra, UPDATE inesperados y conflictos de copias.
Hay también otro efecto que en este tipo de comparativas se suele pasar por alto: si el pedido ya está cargado en el persistence context actual, merge() copiará el estado precisamente en él. Es decir, el pobre detached-patch puede sobreescribir la entidad managed más completa con la que ya estabas trabajando en esa misma transacción.
import jakarta.persistence.EntityManager;
// managed1 ya está en el persistence context (Hibernate lo rastrea)
PurchaseOrder managed1 = entityManager.find(PurchaseOrder.class, 100L);
// detached - instancia separada con el mismo id (p.ej., viene del mapper/DTO)
PurchaseOrder detached = new PurchaseOrder();
detached.setId(100L);
// merge devolverá la referencia al mismo objeto managed que ya estaba en el persistence context
PurchaseOrder managed2 = entityManager.merge(detached);
System.out.println(managed1 == managed2); // true
Esto significa lo siguiente: si en el marco de una misma transacción ya habías modificado algo en managed1 y luego por error llamaste a merge() con detachedPatch, puedes sobreescribir tus propios cambios con el estado más pobre del objeto detached. Y eso parecerá «Hibernate se volvió loco», aunque en realidad solo ejecutó honestamente el contrato de merge(): copió el estado.
4. Opción B: find() + mutate
Ahora procesamos el mismo OrderEditRequest con el otro flujo. Aquí el request no es «casi-una-entidad», sino un comando: primero leemos el pedido actual de la BD y luego aplicamos solo los cambios que realmente llegaron.
package com.example.commerce.orders.service;
import com.example.commerce.orders.dto.OrderEditRequest;
import com.example.commerce.orders.entity.PurchaseOrder;
import com.example.commerce.orders.entity.OrderItem;
import com.example.commerce.orders.entity.OrderStatus;
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderEditingService {
private final EntityManager entityManager;
public OrderEditingService(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional
public void editOrderFindAndMutate(OrderEditRequest request) {
// Cargamos explícitamente el pedido: ahora es una entidad managed (la fuente de verdad)
PurchaseOrder order = entityManager.find(PurchaseOrder.class, request.orderId());
// Aplicamos los cambios puntualmente: modificamos solo lo que llegó como "comando"
order.setStatus(OrderStatus.valueOf(request.newStatus()));
// Importante: actualizamos solo los itemId indicados, el resto de líneas no se toca
for (var patch : request.itemPatches()) {
OrderItem item = entityManager.find(OrderItem.class, patch.itemId());
item.setQuantity(patch.newQty());
}
// Forzamos ver el SQL y el efecto del dirty checking
entityManager.flush();
}
}
El find(OrderItem.class, patch.itemId()) directo aquí es solo para una comparativa SQL uno a uno. En un escenario real de actualización, igualmente es importante comprobar que la línea pertenece realmente a ese pedido, o modificarla a través del agregado con un método helper. De lo contrario hasta el flujo seguro se convierte pronto en «encontramos cualquier hijo por id y lo cambiamos calladamente».
Sí, hay más líneas. Pero cada una refleja un cambio concreto: encontramos el pedido, cambiamos el estado, encontramos las líneas indicadas, cambiamos la cantidad. Si en el request no hay otros campos ni otros items, Hibernate no recibe ningún motivo para tratarlos como nuevo estado de todo el grafo.
5. Comparativa: SQL y semántica
Ahora comparamos no dos abstracciones, sino la reacción de Hibernate al mismo OrderEditRequest. La pregunta ya no es solo cuántas queries hay, sino cuán predeciblemente trata cada rama la entrada parcial.
Desde el punto de vista del SQL, find() + mutate suele verse así:
-- leemos explícitamente el pedido como la verdad actual
select ... from purchase_order where id = ?;
-- después leemos solo las líneas que realmente llegaron en el parche
select ... from order_item where id = ?;
-- y en flush van exactamente los cambios que hicimos
update purchase_order set status = ? where id = ?;
update order_item set quantity = ? where id = ?;
Desde el punto de vista de la semántica de negocio, la diferencia es esta: con find() + mutate piensas más en términos de comandos de cambio, y con merge() piensas en términos de transferencia de estado. Mientras la entrada sea completa, esto es tolerable. En cuanto el request solo lleva el estado y unos pocos itemPatches, merge() empieza a adivinar por ti lo que no querías tocar.
Para fijar la comparativa de la forma más clara posible, juntemos las diferencias en una tabla.
| Criterio | merge() (detached → copy state) | find() + mutate (load → apply changes) |
|---|---|---|
| Fuente de verdad | El objeto detached que trajiste | BD → entidad managed en el persistence context |
| Qué se necesita de los datos de entrada | Preferiblemente un snapshot completo del pedido y el grafo necesario | Basta con enviar solo lo que cambia |
| Trabajo con el objeto devuelto | Hay que usar obligatoriamente el objeto que devuelve merge() | Trabajas con una sola instancia managed |
| Riesgo de «sobreescritura accidental» | Alto, especialmente con partial DTO y grafos | Menor: solo cambias lo que escribiste explícitamente en el código |
| Visibilidad del SQL «por el código» | Parte del SELECT está oculto dentro de merge() | El SELECT es generalmente explícito — a través de find() |
| Debug y code review | Más difícil: «¿qué exactamente estamos copiando?» | Más fácil: «mira qué campos tocamos» |
Y ahora lo más importante: esta no es una tabla de «merge() es malo, find() es bueno». Es una tabla de «merge() exige disciplina y datos completos, y find() + mutate es el valor predeterminado más seguro para cambios de negocio».
6. Cómo elegir el enfoque
Para no tener todo esto en la cabeza como ideas sueltas, es útil tener un pequeño diagrama de decisión. No es sobre «correcto / incorrecto», sino sobre lo que realmente controlas: la completitud de la entrada y la previsibilidad del resultado.
flowchart TD
A["Necesitas actualizar el pedido"] --> B{"¿Los datos de entrada son un snapshot completo del pedido (todos los campos importantes + todo el grafo necesario)?"}
B -->|Sí| C{"¿Estás dispuesto a trabajar solo con la instancia managed devuelta por merge y a leer el SQL trace?"}
C -->|Sí| D["Puedes usar merge (pero con cuidado, sin mezclar flujos)"]
C -->|No| E["Mejor find + mutate (menos magia, más fácil de mantener)"]
B -->|No| E["find + mutate (actualización selectiva)"]
En una edición típica de backoffice, la entrada casi siempre es incompleta: el operador cambió un campo, la UI envió un parche. En ese modelo, find() + mutate gana no por elegancia, sino por previsibilidad ingenieril. merge resulta más adecuado donde el objeto detached es realmente un snapshot completo y ordenado, y no intentas usarlo como documento PATCH.
Hay también una disciplina importante adicional: no mezclar los flujos en un mismo método. El compromiso típico que parece inteligente pero suele generar confusión suena así: «Haré merge() y luego corregiré los campos a mano». El problema es que ahora tienes dos referencias — detached y managed —, y fácilmente puedes empezar a modificar la equivocada. Además, puedes sobreescribir inadvertidamente el resultado de tus propios cambios manuales con otro merge() de otro objeto.
7. Errores típicos al comparar merge() y find() + mutate
Error n.º 1: usar merge() como «actualización parcial por id».
Trampa muy frecuente: el desarrollador crea un objeto PurchaseOrder nuevo, asigna solo id, status y un par de itemPatches, hace merge() y espera que Hibernate actualice solo eso. Hibernate no piensa así: copia el estado completo. Si el resto de campos y relaciones no están definidos, o los sobreescribirás con valores por defecto, o obtendrás un error de restricciones en el flush().
Error n.º 2: ignorar el objeto devuelto por merge().
Aquí una sola línea de código te ahorra horas: guarda siempre el resultado de merge() en una variable y trabaja solo con él. Si sigues modificando la instancia detached antigua, esos cambios no quedarán bajo dirty checking, y te preguntarás sinceramente por qué «Hibernate no guardó nada».
Error n.º 3: mezclar merge() y find() + mutate en el mismo flujo de actualización «por si acaso».
Cuando en un mismo método aparecen find(), luego merge() y luego cambios directos, obtienes una contabilidad doble de entidades. En esa situación hasta un desarrollador experimentado puede no saber de inmediato qué objeto es managed, cuál es detached y qué irá a la BD después del flush().
Error n.º 4: elegir merge() solo porque «tiene menos líneas».
La cantidad de líneas es un criterio pésimo para el código de persistencia. Con merge(), la mayor parte de la complejidad está oculta no en las líneas Java, sino en las acciones implícitas: crear o buscar la instancia managed, posibles lecturas de BD, cascadas sobre el grafo y copia del estado de las colecciones. find() + mutate suele ser más largo, pero es más fácil de explicar y de diagnosticar.
Error n.º 5: comparar los enfoques sin mirar el SQL trace.
A nivel de código Java, ambos enfoques pueden parecer casi idénticos. La diferencia a menudo se manifiesta en el rastro SQL: dónde aparecieron los SELECT extra, por qué de repente se actualizaron más columnas de lo esperado, o por qué el update tocó no solo la tabla raíz. Si comparas merge() y find() + mutate sin mirar el SQL, estás comparando sensaciones, no comportamiento.
GO TO FULL VERSION