1. De dónde viene el UPDATE inesperado
Un UPDATE inesperado en Hibernate casi siempre significa una sola cosa: en algún lugar dentro de la transacción cambió una entidad managed. Si estás acostumbrado al "SQL manual", el cerebro espera algo diferente: el UPDATE aparece solo donde tú mismo lo escribiste o llamaste explícitamente a save(). El ORM rompe ese reflejo. Aquí el UPDATE no es un comando del código, sino una consecuencia de que el objeto managed cambió dentro de la transacción.
Una vez que eso queda claro, surge la siguiente pregunta de ingeniería: ¿por qué a veces la actualización se va en un lugar donde el código parece lectura? Eso es el accidental update.
Fijemos de la manera más terrenal posible. Al final de la transacción Hibernate sincroniza el persistence context con la base. Para eso mira las entidades managed: si el estado actual difiere del snapshot, está obligado a preparar la actualización. La trampa está en que puede que no quisieras cambiar los datos, pero de todos modos en algún sitio cambiaste un campo del objeto managed. Hibernate no sabe leer tus intenciones — sabe leer los cambios en memoria.
Visualmente queda así:
flowchart TD
A["@Transactional: comenzó la transacción"] --> B["find(): obtuvimos la entidad managed"]
B --> C["El código 'casi lee'..."]
C --> D["...pero en algún sitio cambia el campo (setter/mapper/efecto secundario)"]
D --> E["Al final de la transacción: dirty checking"]
E -->|hay diferencia con snapshot| F["Preparamos SQL UPDATE"]
E -->|no hay diferencias| G["SQL UPDATE no es necesario"]
Observa: en este esquema no hay un paso obligatorio "llamamos a save()". Y eso es normal. Hibernate trabaja según el modelo "cambió el objeto → el ORM sincroniza".
2. Accidental update: sentido y causas
El término accidental update suena casi como un diagnóstico, y más o menos lo es. Es la situación en que el UPDATE en el log SQL realmente ocurrió, pero desde el punto de vista de la lógica de negocio lo consideras "de más" o "inesperado". Lo importante es no confundirse: eso no significa que Hibernate "actualizó algo por sí mismo". Significa que dentro de la transacción realmente cambió el estado del objeto managed, simplemente no lo planeabas como cambio de negocio.
Para no discutir con la realidad, conviene separar dos casos. Actualización intencionada — cuando el escenario es explícitamente de escritura: "renombrar el producto", "confirmar el pedido", "ocultar el producto del catálogo". Actualización accidental — cuando el escenario parece lectura o preparación de respuesta, pero dentro de él ocurre "un pequeño arreglo de los datos": recortaron espacios, normalizaron el registro, corrigieron el formato, recalcularon el campo "por si acaso", actualizaron el campo de servicio de tiempo, etc.
A continuación una tablita que ayuda a mantener esa distinción en la mente:
| Escenario en el código | Cómo parece la intención | Qué ocurrió realmente | Por qué Hibernate tiene razón |
|---|---|---|---|
| Método "armar el título del producto" | lectura/formateo | product.setName(...) | el campo cambió respecto al snapshot |
| Método "mostrar el pedido en el panel de administración" | lectura/visualización | order.setStatus(...) en algún rincón | el estado cambió, por tanto se necesita UPDATE |
| Mapeador DTO "aplicar patch" | actualizar 1-2 campos | el mapeador puso null o valores por defecto en otros | el estado de la entidad se volvió diferente |
La disciplina clave para el desarrollador: cuando ves un accidental update, no preguntes "¿dónde está el bug de Hibernate?", pregunta "¿qué campo se volvió diferente y quién lo cambió?". Hibernate en esta historia normalmente es simplemente un contador honesto: vio la diferencia — la anotó en el libro mayor.
3. El culpable más frecuente: "lectura con efecto secundario"
En la mayoría de los proyectos el accidental update no aparece por anotaciones exóticas o mecanismos ocultos, sino por un hábito muy humano: "voy a arreglar los datos aquí discretamente, de paso". Esos "de paso" se disfrazan perfectamente de lectura. El método devuelve una cadena, un DTO o simplemente hace logging — y parece que "no cambia nada". Pero si dentro hubo un setter, el ORM lo percibirá como un cambio.
Mira el ejemplo — en proyectos reales aparece mucho más de lo que se querría. El desarrollador quiere mostrar un título bonito y de paso decide "limpiar" el nombre:
import org.springframework.transaction.annotation.Transactional;
@Transactional
public String loadProductTitle(Long id) {
// Cargamos la entidad dentro de la transacción: se vuelve managed
Product product = entityManager.find(Product.class, id);
// Parece una "normalización inocente" para la UI,
// pero es un cambio real de la entidad managed en el persistence context
product.setName(product.getName().trim());
// Aquí ya leemos el valor cambiado, y al final de la transacción puede irse un UPDATE
return product.getSku() + " " + product.getName();
}
Si en la base el nombre era " Keyboard " (con espacios), después de trim() será "Keyboard". Para Java es simplemente una cadena. Para Hibernate es un cambio real del estado de la entidad managed. En el log SQL verás UPDATE, aunque en ningún sitio haya save().
Hay que subrayar un punto importante por separado: si trim() no cambió la cadena, el UPDATE puede no aparecer, porque el dirty checking compara el snapshot con el estado actual. Precisamente por eso ese bug a veces "aparece y desaparece", y de eso se vuelve aún más místico. La mística es simple: los datos de entrada son diferentes.
La versión segura del mismo método. Seguimos normalizando para la visualización, pero no tocamos la entidad:
import org.springframework.transaction.annotation.Transactional;
@Transactional
public String loadProductTitleSafely(Long id) {
// La entidad es managed, pero no cambiamos su estado
Product product = entityManager.find(Product.class, id);
// Normalizamos solo para la visualización: es una variable local, no una escritura en la entidad
String normalizedName = product.getName().trim();
// En el persistence context el estado de la entidad no cambió — por tanto el UPDATE no es obligatorio
return product.getSku() + " " + normalizedName;
}
El resultado para el usuario es el mismo, pero el UPDATE no surgirá, porque el estado del objeto managed no cambió. Aquí normalmente se quiere decir: "pero eso es obvio". Sí, hasta el primer agobio, cuando estás cansado y te parece que setName(trim()) no es escritura, sino cosmética. Hibernate en respuesta dice con calma: "¿cosmética? perfecto, lo anoto".
Otra variante de "lectura con efecto secundario" — cuando la normalización está oculta en un método separado de la entidad y por eso parece aún más legítima. Por ejemplo, creaste el método de dominio normalizeName() y lo llamas "en la lectura":
import org.springframework.transaction.annotation.Transactional;
@Transactional
public String titleWithNormalize(Long id) {
// Cargamos la entidad managed
Product product = entityManager.find(Product.class, id);
// Importante: si dentro de normalizeName() hay setters, de todas formas es escritura
product.normalizeName();
// Externamente el método parece "lectura", pero el ORM verá los cambios de los campos
return product.getSku() + " " + product.getName();
}
A nivel de idea los métodos de dominio son buenos. A nivel de accidental update lo importante es recordar: si dentro de normalizeName() se cambian campos, eso es escritura, aunque hayas llamado al método de forma suave y agradable. El ORM no lee los nombres de los métodos, lee los cambios de los campos.
4. Setters con sorpresa: cuando la asignación cambia algo más
El siguiente por popularidad origen del accidental update son los setters con efectos secundarios. En el "Java puro" un setter es normalmente un simple this.x = x;. En el proyecto real el setter a menudo se convierte en una mini-operación: no solo asigna el valor, sino que lo normaliza, recalcula campos derivados, cambia marcas de servicio. Puede que esté justificado, pero entonces hay que reconocer honestamente: ese setter es parte del modelo de escritura, y no debes llamarlo en escenarios de lectura.
Imaginemos que decidimos actualizar automáticamente updatedAt al cambiar el nombre del producto. La variante naïf tiene este aspecto:
import java.time.Instant;
public void setName(String name) {
// Cambiamos el "campo de negocio"
this.name = name;
// Efecto secundario: cambiamos el campo de servicio de tiempo
// Aunque name no haya cambiado por sentido, updatedAt casi siempre cambiará
this.updatedAt = Instant.now();
}
¿Cuál es la trampa? Aunque por sentido "no cambiabas nada", pero el setter se llamó (por ejemplo, en el mapeador), updatedAt casi seguro cambiará. Hibernate verá la diferencia entre el snapshot y el estado actual y enviará UPDATE. En el SQL esto parece "actualizaron el producto", aunque pudiste estar simplemente "armando el título" o "aplicando un DTO donde el nombre era el mismo".
Una variante aún más traicionera — cuando el setter hace normalización y al mismo tiempo toca otro campo, por ejemplo el de búsqueda:
import java.util.Locale;
public void setName(String name) {
// Guardamos "tal cual" (o casi tal cual)
this.name = name;
// Campo adicional para búsqueda/indexación:
// aunque name sea "el mismo", searchName puede recalcularse diferente por trim/mayúsculas
this.searchName = name == null ? null : name.trim().toLowerCase(Locale.ROOT);
}
Ahora incluso la "inocente" operación setName(product.getName()) puede convertirse en un cambio, si searchName se recalcula diferente — por ejemplo, por espacios, mayúsculas o localidades distintas. El bug parece especialmente raro porque "pusiste lo mismo". Pero en la práctica no pusiste lo mismo — recalculaste también otro campo.
En esta etapa del curso no necesitamos prohibir los setters. Necesitamos trazar una frontera honesta: si el setter tiene efectos secundarios, ya no es "simplemente asignación". Hay que llamarlo con la misma conciencia con que escribirías SQL manual. Lanzar ese setter en un método que se posiciona como lectura — es como si en el método getReport() silenciosamente ejecutaras DELETE FROM ... "para que la tabla esté más limpia". Formalmente se puede, pero en el code review te querrán de forma muy... peculiar.
5. Mapeado DTO: actualización de todo
El tercer gran origen del accidental update — la copia ciega de datos del DTO a la entidad. La ironía es que el mapeado normalmente se escribe con buenas intenciones: "quiero aplicar ordenadamente los datos de la petición". Pero si se copia todo sin más, tarde o temprano copiarás lo que no hace falta. Hibernate en ese lugar, como siempre, es consecuente: copiaste lo que no hacía falta — obteniste el UPDATE que no hacía falta.
Supongamos que tenemos una petición de actualización del producto. Por simplicidad usamos un Java record:
// DTO de la petición: puede llegar parcialmente relleno (p. ej., name=null si el campo no se envió)
public record UpdateProductRequest(String name, ProductStatus status) {
}
Ahora el mapeador naïf (o simplemente código naïf del servicio) puede tener este aspecto:
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void updateProduct(Long id, UpdateProductRequest dto) {
// Cargamos la entidad managed: cualquier cambio en ella será rastreado
Product product = productRepository.findById(id).orElseThrow();
// Peligro: si dto.name() == null, sobreescribiremos el campo en la base
product.setName(dto.name());
// Peligro: si no queríamos cambiar el campo, pero llegó el valor "por defecto" — también lo sobreescribiremos
product.setStatus(dto.status());
}
El problema no está en que "así no se puede". El problema está en que esa actualización no distingue las intenciones. Si dto.name() llegó como null (porque el cliente no quería cambiar el nombre, simplemente no envió el campo), sobreescribirás el nombre en la base. Hibernate enviará UPDATE, y eso será 100% lógico. Solo el resultado de negocio resultará ilógico.
El segundo problema típico — cuando el DTO contiene el valor "tal cual" (por ejemplo "Keyboard "), y el setter lo normaliza. Entonces de nuevo obtienes un accidental update solo a través del mapeador: pareces que simplemente "reasignaste el nombre", pero en realidad cambiaste el valor normalizado.
Hay un estilo más cuidadoso — el selective mapping: aplicar solo los campos que realmente deben cambiar. A nivel de código esto a menudo parece aburrido (y el código aburrido en el persistence layer es normalmente buena señal):
import java.util.Objects;
public void apply(UpdateProductRequest dto, Product product) {
// Aplicamos el campo solo si realmente llegó en la petición y realmente es diferente
if (dto.name() != null && !Objects.equals(dto.name(), product.getName())) {
// Así no tocamos el setter de más (y el setter puede tener efectos secundarios)
product.setName(dto.name());
}
// Para el enum normalmente basta con la comparación de referencia, pero también verificamos null
if (dto.status() != null && dto.status() != product.getStatus()) {
product.setStatus(dto.status());
}
}
Aquí hacemos dos cosas que ahorran horas de investigaciones. Primero, no convertimos la ausencia del campo en el DTO en una orden de "sobreescribir el valor en la base con null". Segundo, no llamamos al setter si el valor es el mismo de todas formas. Eso no es solo sobre la optimización, aunque también es agradable; es también sobre reducir el riesgo de tocar accidentalmente un efecto secundario dentro del setter.
Es importante entender el matiz: llamar al setter por sí solo todavía no garantiza un UPDATE — eso ya lo sabemos. Pero llamar al setter aumenta la probabilidad de cambio, porque dentro puede haber normalización, actualización de campos de servicio o modificación de valores relacionados. Por eso reducir "las llamadas innecesarias al setter" es una protección de ingeniería real, no una cuestión de gustos.
6. Investigación del UPDATE inesperado: búsqueda de la causa
Cuando por primera vez atrapas un unexpected UPDATE, se quieren hacer dos cosas: culpar a Hibernate y desactivar el SQL log para no ver ese horror. Mejor juguemos al detective. Ya tenemos todas las pistas: el estado managed, el modelo snapshot y la huella SQL como resultado. Eso es suficiente para encontrar, sin magia, la línea concreta donde el estado cambió.
Empezar la investigación es útil no desde el código, sino desde una pregunta simple: "¿qué tabla exactamente se actualizó?". Si el UPDATE fue a products, entonces cambió Product. Si fue a purchase_orders, entonces cambiaron PurchaseOrder. Luego miramos la lista de columnas en SET ... y entendemos aproximadamente qué campos están potencialmente afectados. En ese punto todavía no hace falta discutir "por qué hay tantas columnas". Estamos buscando el origen del cambio, no optimizando la forma del UPDATE.
El siguiente paso — asegurarse de que la entidad era realmente managed en la transacción actual. Si la obtuviste a través de entityManager.find(...) o repository.findById(...) dentro de @Transactional, eso casi siempre es el escenario managed. Si el objeto llegó de una capa externa y ya es detached, esa es otra historia y otros mecanismos — pero no el de hoy.
Después de eso hay dos recursos muy prácticos.
El primero — el "termómetro" diagnóstico de Hibernate: Session.isDirty(). Muestra si Hibernate considera la sesión actual "sucia", es decir si hay cambios en ella. Es una API específica de Hibernate, por eso sacamos la Session a través de unwrap():
import org.hibernate.Session;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void debugUnexpectedUpdate(Long id) {
// Cargamos la entidad managed
Product p = entityManager.find(Product.class, id);
// Sacamos la Session de Hibernate (API específica, no JPA)
Session session = entityManager.unwrap(Session.class);
// Antes de los cambios la sesión está "limpia"
System.out.println(session.isDirty()); // false
// Hacemos un cambio "inocente" — para Hibernate ya es motivo para estar dirty
p.setName(p.getName().trim());
// Después del cambio la sesión se vuelve "sucia" — significa que en flush/commit probable UPDATE
System.out.println(session.isDirty()); // true
}
Este código es bueno porque convierte la "sensación" en un hecho. Si antes de tu acción "inocente" isDirty() era false, y después se convirtió en true, significa que en algún sitio cambiaste el estado managed. Más adelante ya es cuestión de técnica: qué exactamente y dónde.
El segundo recurso — usar detach() como "cinta de aislamiento". Ya sabemos del día anterior: el objeto detached no participa en el dirty checking. Por eso si quieres comprobar la hipótesis "el UPDATE aparece por el objeto managed sucio", puedes temporalmente separar la entidad del contexto y ver si desaparece la actualización. No es curación, sino diagnóstico, como "desactivar la protección y ver si deja de chispear". Ejemplo:
import org.springframework.transaction.annotation.Transactional;
@Transactional
public String titleButDetached(Long id) {
// Primero la entidad es managed
Product product = entityManager.find(Product.class, id);
// La "desconectamos" del persistence context: a partir de ahí es simplemente un objeto Java
entityManager.detach(product);
// Ahora los cambios no llegarán al dirty checking y no provocarán UPDATE
product.setName(product.getName().trim());
// Para la lógica de la aplicación puede estar bien, pero hay que recordar: trabajas con detached
return product.getSku() + " " + product.getName();
}
Si después de detach() el UPDATE desapareció, significa que la causa era realmente el dirty checking de la entidad managed, no algún "save oculto". Eso estrecha muy rápidamente el círculo de búsqueda.
Y finalmente, la forma más directa de encontrar al culpable — poner un breakpoint en el setter concreto y mirar el call stack. Suena banal, pero funciona casi siempre. Tu tarea no es "entender Hibernate en general", sino encontrar quién exactamente en tu transacción tocó el campo. Muy a menudo resulta ser una capa inesperada: algún "formateador para la UI", "normalizador" o "mapeador" que considerabas inofensivo.
Para no convertir el diagnóstico en caos, es conveniente tener a la vista una pequeña tabla de herramientas y su sentido:
| Herramienta | Qué da | Cómo ayuda con el accidental update |
|---|---|---|
| SQL trace | hecho del UPDATE, tabla, a menudo columnas | confirma que la actualización realmente fue |
| Session.isDirty() | "¿la sesión está sucia o no?" | muestra el momento en que aparecieron los cambios |
| detach() | excluye el objeto del dirty checking | permite confirmar/refutar rápidamente la hipótesis |
| breakpoint en el setter | call stack | muestra la línea exacta del culpable |
Observa: ningún punto requiere "añadir más anotaciones". El accidental update casi nunca se trata con una anotación. Se trata con disciplina: saber dónde escribes y dónde lees.
7. Reglas que ahorran horas
Cuando el accidental update ya ha mordido una vez, aparece la tentación de pasar al modo "nunca tocar la entidad a mano". Eso también es un extremo. No necesitamos paranoia, sino un estilo cuidadoso. El hábito más útil — dividir mentalmente el código en ruta de lectura y ruta de escritura, aunque técnicamente ambas vivan en la misma transacción y trabajen con las mismas entidades.
En la ruta de lectura intenta no llamar a métodos que cambien el estado de la entidad, aunque "simplemente normalicen". Cualquier cálculo para la visualización es mejor hacerlo en variables locales: así es más seguro y más legible. Si muy a menudo quieres "arreglar los datos" durante la lectura, es más honesto convertir eso en un escenario de escritura explícito: un método de servicio separado, una intención separada — y entonces el UPDATE ya no sorprende, porque lo esperabas.
En la ruta de escritura, al contrario, no tengas miedo de que save() no se llamó. Si la entidad es managed, el dirty checking hará su trabajo. Pero presta atención al mapeado: actualiza solo lo que realmente debe actualizarse. Eso no es "código de más", sino el contrato de tu escenario. Y si la simple aplicación del DTO provoca cambios inesperados, consiérate avisado: tienes mezclados "datos para visualizar" y "datos para escribir" o los setters son demasiado listos para su propio bien.
Y un principio práctico más que al principio parece pesado, y luego se convierte en el favorito: si un método se llama como lectura (getTitle(), buildSummary(), toDisplayString()), no debe cambiar el estado de la entidad. Las sorpresas en esos lugares son el tipo de sorpresas más caro, porque nadie las espera ahí.
8. Errores típicos al buscar y corregir el accidental update
Error #1: buscar el UPDATE solo donde hay save().
Después de Spring Data muchos se acostumbran a considerar save() la principal fuente de escritura. En Hibernate la escritura real aparece no "por el save", sino por la diferencia entre el snapshot y el estado actual de la entidad managed. Por eso la ausencia de save() no significa ausencia de escritura, y la presencia de save() no significa que la escritura fuera necesaria.
Error #2: hacer "normalización inocente" a través de setter en el método de lectura.
trim(), toUpperCase(), formateo, sustitución de valores por defecto — todo eso parece cosmética hasta que recuerdas que el objeto es managed. Para Hibernate eso no es cosmética, sino un cambio de datos. Si la normalización solo es necesaria para la visualización, guárdala en una variable local y no toques la entidad.
Error #3: mapear ciegamente el DTO a la entidad y considerarlo "una actualización cuidadosa".
El DTO a menudo llega con campos parcialmente rellenos, nulls, valores por defecto o valores "como se introdujeron". Si mecánicamente asignas todo eso a la entidad, obtienes cambios que no planeabas. El selective mapping es aburrido, pero mantiene el contrato de la actualización en tus manos.
Error #4: escribir setters con efectos secundarios y olvidar que son secundarios.
El setter que actualiza updatedAt, recalcula campos de búsqueda o activa lógica adicional, se convierte en una pequeña operación de negocio. A veces está bien, pero entonces ese setter no puede usarse en lugares que consideras "lectura". De lo contrario tú mismo creas el accidental update, y luego te sorprendes de su honestidad.
Error #5: ignorar el momento en que la sesión se volvió dirty e intentar "entender Hibernate por las estrellas".
Si no fijas el momento de aparición de los cambios — a través de Session.isDirty(), breakpoints en los setters o el temporal detach() — la investigación se convierte en adivinanza. Hibernate en ese lugar precisamente es conveniente: da suficiente observabilidad, solo hay que usarla.
GO TO FULL VERSION