1. Proxies y su influencia en equals()
Si todavía piensas que un proxy es algo exótico «para aquellos que leen el código fuente de Hibernate por la noche», tengo buenas noticias para ti: ya estás viviendo con ellos. Simplemente suelen llegar al código en silencio, como un gato a las 3 de la mañana: sin declaración de guerra, pero con consecuencias muy concretas para tu sueño y tu equals().
Los proxies aparecen en los dos lugares más «cotidianos» de nuestro Commerce Persistence Lab. Primero, cuando Hibernate carga perezosamente relaciones to-one (por ejemplo, PurchaseOrder.customer o OrderItem.product). Segundo, cuando tú mismo pides una referencia sin cargarla a través de EntityManager.getReference() (o Spring Data getReferenceById()), porque solo necesitas el identificador o quieres posponer la lectura.
Ya hemos dividido las entidades en dos grupos: Product, Customer y PurchaseOrder se reconocen por una clave de negocio estable, mientras que OrderItem y las entidades de enlace siguen la estrategia «iguales solo después de que aparezca el id». Ahora no cambiamos esta respuesta, sino que observamos qué hace el tiempo de ejecución de los proxies con ella.
Y es en este momento cuando comienza la magia: aparentemente estás comparando «dos Product», pero uno de ellos es en realidad una subclase especial de Product, creada por Hibernate. En Java esto es una situación normal (herencia), pero en equals() muchos escriben una comprobación estricta de clase a través de getClass() … y de repente obtienen false donde, a nivel de dominio, esperan true.
2. Cómo está construido un proxy de Hibernate
Para no tener que adivinarlo a partir de posos de café, acordemos un modelo mental mínimo del proxy. No nos adentraremos en bytecode, ByteBuddy y otras «maravillas» que a veces hacen que uno quiera volver a los archivos CSV. Necesitamos entender lo suficiente para escribir correctamente equals() / hashCode().
Un proxy de Hibernate es un objeto ficticio que parece una entidad, tiene su identificador, pero no contiene el resto de los datos hasta que intentas acceder a ellos. Para ello, el proxy almacena internamente un «inicializador perezoso»: tiene una conexión con la Session / EntityManager actual, y sabe qué SELECT debe ejecutar si quieres leer los campos «reales».
En la imagen se puede representar así:
flowchart TD
A["Tu código order.getCustomer()"] --> B["Proxy de Customer (id conocido, datos aún no)"]
B -->|primer acceso a un campo| C["SELECT ... FROM customer WHERE id=?"]
C --> D["Customer inicializado (campos cargados)"]
El punto clave para la igualdad: el proxy es casi siempre una subclase de tu entidad. Es decir, proxy instanceof Customer será true, pero proxy.getClass() == Customer.class — muy a menudo false.
3. La trampa de getClass() en equals()
En Java puro, una comprobación estricta de clase en equals() parece bastante «correcta»: hace que la comparación sea simétrica y protege contra situaciones extrañas con herencia. Por eso un novato escribe getClass() != other.getClass() y se siente un ingeniero. Hibernate en ese momento también se siente un ingeniero, pero en la dirección opuesta.
Veamos una implementación «estricta» típica que se rompe debido al proxy:
import java.util.Objects;
public boolean equals(Object other) {
// Comprobación estricta de clase: "deben ser exactamente de la misma clase en tiempo de ejecución"
// Con los proxies de Hibernate esto a menudo se rompe, porque el proxy es una subclase de tu entidad.
if (other == null || getClass() != other.getClass()) {
return false;
}
// Después de getClass() se puede hacer el cast de forma segura (pero sigue sin ser seguro para proxies).
Product p = (Product) other;
// Comparación por campo de negocio: parece lógico, pero el problema aquí está precisamente en getClass().
return Objects.equals(getSku(), p.getSku());
}
Ahora hagamos una pequeña observación en nuestro proyecto (al estilo «miremos la realidad, no nuestras esperanzas»). Es importante: el ejemplo siguiente trata sobre diagnóstico. Es útil incluso sin una comprensión completa de los detalles.
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void showProxyClasses(EntityManager em, Long productId) {
// find() devuelve un objeto gestionado "real" (generalmente sin envoltura de proxy)
var managed = em.find(Product.class, productId);
// getReference() devuelve una referencia proxy: id conocido, los campos pueden no estar cargados
var proxy = em.getReference(Product.class, productId);
// managed y proxy tendrán clases de tiempo de ejecución diferentes
System.out.println(managed.getClass()); // class com.example.commerce.catalog.entity.Product
System.out.println(proxy.getClass()); // class com.example.commerce.catalog.entity.Product$HibernateProxy$...
// Si equals() usa getClass(), managed y proxy a menudo resultarán "no iguales"
System.out.println(managed.equals(proxy)); // false (si equals() comprueba getClass())
}
Y aquí está el momento más desagradable: desde el punto de vista del dominio es el mismo producto, desde el punto de vista del identificador de la base de datos también es la misma fila, pero equals() devuelve false porque el proxy es otra clase en tiempo de ejecución.
Y esto no es que «Hibernate se haya roto». Es que hemos aplicado una «regla puramente Java» en un lugar donde el tiempo de ejecución utiliza subclases específicamente como herramienta para la carga perezosa.
4. instanceof para la comprobación de tipo segura para proxies
Después de la sección anterior, dan ganas de agarrar getClass() y eliminarlo solemnemente. A veces, merecidamente. Pero hagámoslo de forma consciente. instanceof es una comprobación de tipo suave: responde a la pregunta «¿esto es al menos un Product?», no «¿esto es exactamente un Product sin mezclas?».
¿Por qué es bueno para Hibernate? Porque el proxy es una subclase de la entidad. Por lo tanto, proxy instanceof Product devuelve true, y no rechazamos el objeto solo porque Hibernate haya decidido envolverlo en una capa perezosa.
La comprobación de tipo mínimamente segura para proxies tiene este aspecto:
import java.util.Objects;
public boolean equals(Object other) {
// Comprobación de tipo suave: el proxy (como subclase) la supera
if (!(other instanceof Product)) {
return false;
}
// El cast es seguro después de instanceof
Product p = (Product) other;
// Importante recordar: instanceof también "deja pasar" a los herederos reales de Product, si existen
return Objects.equals(getSku(), p.getSku());
}
Aquí instanceof deja pasar al proxy. Y esto realmente resuelve el problema principal de getClass().
Pero instanceof tiene un matiz que es importante conocer de antemano: «permite» la comparación con descendientes. En una jerarquía Java normal, esto puede llevar a rarezas si tienes subclases reales que añaden significado (por ejemplo, SpecialProduct extends Product). Para las entidades centrales de nuestro proyecto (Product, Customer, PurchaseOrder, OrderItem) esto no es algo que queramos fomentar, por lo que a menudo se usa un enfoque más estricto, pero aún seguro para proxies, del que trata la siguiente sección.
Para no tener esto en la cabeza como un conjunto de palabras abstractas, es útil ver la diferencia en una tabla:
| Comprobación de tipo en equals() | ¿El proxy la supera? | Rigor | Resultado típico |
|---|---|---|---|
| getClass() == other.getClass() | no | máximo | se rompe con proxies |
| other instanceof Product | sí | suave | funciona con proxies, pero «deja pasar a los herederos» |
| Hibernate.getClass(this) == Hibernate.getClass(other) | sí | estricta | funciona con proxies y no se confunde con los tipos |
5. Hibernate.getClass() como alternativa estricta a instanceof
Cuando quieres dos propiedades simultáneamente — «el proxy no rompe la comparación» y «aun así comparo objetos del mismo tipo de entidad, sin sorpresas de herencia» — Hibernate tiene una utilidad muy práctica: Hibernate.getClass(entidad).
Esta función hace un trabajo importante: si el objeto es un proxy, devuelve la clase de la entidad «real», no la clase del proxy como subclase. Aquí nos interesa precisamente este efecto: comprobar el tipo de forma consciente de proxies y no confundir un proxy con otra entidad. No hay que percibir Hibernate.getClass(...) como una bala de plata para todo el tiempo de ejecución perezoso: es un helper para comprobar el tipo, no una promesa de «ahora no habrá SQL ni efectos secundarios en absoluto».
La línea clave tiene este aspecto:
import org.hibernate.Hibernate;
// dentro de equals(...)
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
Después de esta comprobación, ya estás comparando esa base de identidad que elegiste antes: ya sea la clave de negocio, o el id en el grupo «iguales solo después de que aparezca el id».
6. Getters dentro de equals(): corrección vs SQL adicional
Ahora pasamos a la parte donde el ORM empieza a bromear de forma especialmente sutil. Supongamos que decides comparar Product por su clave de negocio sku. Parece lógico: sku es único, estable, las integraciones lo conocen y es más importante a nivel de dominio que el id. Pero en el mundo de los proxies, esta idea tiene dos caras inesperadas.
La primera cara — si en equals() accedes al campo directamente, y no a través de un getter, corres el riesgo de obtener un valor «vacío» en un proxy no inicializado. El proxy no es un objeto normal; puede contener campos en estado predeterminado hasta la inicialización. Entonces this.sku podría resultar null, aunque en la base de datos sku exista.
La segunda cara — si accedes al getter, el proxy puede decidir: «¡Oh! Se necesitan datos, ¡entonces es hora de cargarlos!» y ejecutar un SELECT. Es decir, un equals() normal se convierte repentinamente en una operación con E/S. Y a veces esto lleva a sorpresas del tipo «¿por qué mi contains() en un HashSet genera consultas SQL?».
Para verlo con los ojos, basta recordar algo simple: el proxy conoce con certeza el id, pero no está obligado a conocer el resto de los campos.
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void proxyInitializationDemo(EntityManager em, Long productId) {
// Obtenemos un proxy: en este momento el objeto puede no estar inicializado
Product proxy = em.getReference(Product.class, productId);
// El id suele estar disponible sin SELECT (Hibernate lo almacena directamente en el proxy)
System.out.println(proxy.getId()); // 1
// El acceso a un campo de negocio puede requerir inicialización y provocar un SELECT
System.out.println(proxy.getSku()); // puede llamar a SELECT para inicializar el proxy
}
Precisamente por eso el proxy no redefine la estrategia que ya has elegido, sino que muestra su coste. Para el grupo basado en id, esto suele ser más barato: el proxy conoce el id por sí mismo y no está obligado a traer el resto de los campos. Para el grupo de clave de negocio, el coste es mayor: equals() y hashCode() pueden convertirse en un desencadenante de la carga si la clave solo está disponible después de inicializar el proxy.
Esto no significa que la clave de negocio sea mala. Significa que Product.sku, Customer.email y PurchaseOrder.orderNumber siguen siendo una base funcional para la igualdad, pero ya no parecen una operación «gratuita» en escenarios con proxies. Por lo tanto, es conveniente analizar ambos grupos por separado: primero el basado en id, luego el de clave de negocio.
7. Igualdad para entidades con ID generado
En nuestro proyecto hay muchas entidades cuya identidad en la base de datos se expresa mediante un ID generado: OrderItem, ProductCategoryAssignment, CustomerAddress, InventoryItem y otras. Para ellas, la clave de negocio o no existe, o no es lo suficientemente «sólida» como para basar la igualdad en ella. Por lo tanto, necesitamos una plantilla que no se desmorone con los proxies y no se dispare en el pie con id == null.
Para ellas no reinventamos nada: simplemente hacemos segura para proxies la estrategia basada en id que ya elegimos.
A continuación, una opción práctica que soporta bien tanto los proxies como id == null, y no hace que «dos nuevas entidades sean iguales»:
import org.hibernate.Hibernate;
import java.util.Objects;
@Override
public final boolean equals(Object o) {
// Camino rápido para la comparación de referencias
if (this == o) return true;
// Comprobación de tipo segura para proxies y suficientemente estricta
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
OrderItem other = (OrderItem) o;
// Hasta que aparezca el id, la entidad es única solo como objeto en memoria,
// por lo que la igualdad por id solo se incluye si el id ya está asignado.
return id != null && Objects.equals(id, other.id);
}
Y hashCode() — estable y no ligado al id generado:
@Override
public final int hashCode() {
// hashCode estable: no depende del id generado y no cambia después de persist()/flush()
return 31;
}
Este enfoque a veces se denomina «igualdad basada en id después de la persistencia»: hasta que aparece el id, la entidad es única solo como objeto en memoria (es decir, this == other); después de que aparece el id, como una fila en la base de datos.
Y sí, este es exactamente el caso en el que hashCode() parece «demasiado simple». Pero es precisamente por eso que lo amamos: no cambia, no depende de campos mutables y no convierte un HashSet en una odisea.
8. Igualdad para entidades con clave de negocio
Ahora pasamos a aquellas entidades donde el canon ya está elegido por dominio: Product.sku, Customer.email, PurchaseOrder.orderNumber. El proxy no anula esta elección. Solo añade dos requisitos: la comprobación de tipo debe ser segura para proxies, y el coste de la comparación debe verse de forma honesta.
Si la clave se lee a través de un getter, un proxy sin inicializar puede inicializarse y acudir a la base de datos. Ese es el coste de la igualdad basada en clave de negocio. Pero la base de la comparación no cambia: Product sigue reconociéndose por su sku, y no repentinamente por un id generado.
Por ejemplo, para Product, la versión segura para proxies tendrá este aspecto:
import org.hibernate.Hibernate;
import java.util.Objects;
@Override
public final boolean equals(Object o) {
// Camino rápido: el mismo objeto
if (this == o) return true;
// Comprobación de tipo segura para proxies
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
Product other = (Product) o;
// La base de la identidad no cambia: Product se reconoce por sku.
// Es importante entender el coste: el getter en un proxy sin inicializar puede iniciar la carga.
return Objects.equals(getSku(), other.getSku());
}
@Override
public final int hashCode() {
// Construimos el hash basándonos en la misma clave estable.
// Si el objeto mismo es un proxy sin inicializar, esta operación tampoco es "gratuita".
return Objects.hash(getSku());
}
Aquí es importante no ver una nueva receta, sino un antiguo acuerdo bajo una luz diferente. Product sigue reconociéndose por su sku; Customer — por su email; PurchaseOrder — por su orderNumber. La parte segura para proxies aquí es la comprobación de tipo a través de Hibernate.getClass(...). Y el riesgo de inicialización adicional no desaparece: si la clave de negocio solo está disponible después de cargar el proxy, Hibernate puede acudir a la base de datos.
Por lo tanto, la igualdad basada en clave de negocio funciona bien donde la clave es realmente estable y no pretendes que equals() / hashCode() sean matemáticas puras sin coste en tiempo de ejecución.
9. Demostración: find() vs getReference() y equals()
Para que esto no se quede en una teoría del tipo «créeme, he visto producción», vamos a montar un pequeño escenario con las mismas herramientas que ya hemos utilizado en el curso: EntityManager.find(), EntityManager.getReference() y el límite controlado del contexto de persistencia a través de clear().
La idea es la siguiente: primero obtenemos un objeto completo, luego limpiamos el contexto de persistencia (lo dejamos detached), después tomamos una referencia proxy y comparamos. Si equals() está escrito con getClass() — habrá una sorpresa. Si es seguro para proxies — todo será predecible.
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void compareDetachedAndProxy(EntityManager em, Long productId) {
// Primero obtenemos una entidad gestionada
Product detached = em.find(Product.class, productId);
// Limpiamos el contexto de persistencia: el objeto se vuelve detached
em.clear(); // ahora detached es realmente detached
// Luego obtenemos un proxy para el mismo id
Product proxy = em.getReference(Product.class, productId);
// Con equals seguro para proxies, esta comparación debe ser predecible
System.out.println(detached.equals(proxy)); // true con equals seguro para proxies; en el grupo de clave de negocio, esta comparación puede iniciar el proxy
}
Si ahora mismo estás pensando: «Pero ¿para qué comparar detached y proxy?» — este es precisamente el caso que en aplicaciones reales surge de forma inadvertida. Por ejemplo, guardas una entidad en alguna capa, la pasas más adelante (mapeo a DTO, otro servicio, otro caso de uso), y luego la comparas con un objeto que llegó de otra transacción. O la colocas en una colección donde equals() se llama internamente en remove().
Y entonces descubres repentinamente que tu «estricto» getClass() no era estricto, sino simplemente triste.
10. Errores típicos en equals() seguro para proxies
En este tema, los errores son particularmente traicioneros: el código compila, a veces incluso «parece funcionar», y luego empieza a romperse en los lugares más inoportunos: en colecciones, métodos auxiliares, orphan removal, logging. Por eso es útil conocer de antemano cómo son los rastrillos clásicos para no pisarlos con carrera.
Error nº1: usar getClass() en equals() para entidades que pueden ser proxies.
Esto parece «correcto» según el libro de texto de Java, pero se estrella contra la realidad de Hibernate. El resultado suele ser «tengo dos objetos del mismo producto, pero no son iguales», y entonces empiezan a fallar Set.remove(), contains() y toda la lógica que depende de la igualdad.
Error nº2: comparar proxies por campos mediante acceso directo al campo, no a través de getter.
En un proxy no inicializado, los campos pueden estar en estado predeterminado, y obtienes una comparación de null con null que no refleja en absoluto los datos en la base de datos. El getter, a diferencia de la lectura directa del campo, le da al proxy la oportunidad de manejar correctamente el acceso (incluso hasta la inicialización). Pero por ello pagas el riesgo de SQL adicional — por lo tanto, este enfoque debe usarse con cuidado.
Error nº3: construir la igualdad basada en una clave de negocio y no darse cuenta de que equals() se ha convertido en un desencadenante de SQL.
Este es ese momento en el que un inocente set.contains(entidad) se convierte inesperadamente en una consulta a la base de datos. En un conjunto de datos pequeño no lo notarás, pero en uno real obtendrás un «¿por qué tenemos N+1 en una operación donde no estábamos leyendo la relación?». En el curso avanzado, aprendemos precisamente a ver esas cosas a través del trace SQL.
Error nº4: escribir igualdad basada en id como Objects.equals(id, other.id) sin comprobar id != null.
Así, dos entidades nuevas con id == null se vuelven iguales, y un HashSet empieza a «fusionar» objetos diferentes. Esto es especialmente destructivo para entidades hijas/de enlace (por ejemplo, OrderItem o ProductCategoryAssignment), porque son precisamente ellas las que viven más a menudo en colecciones y de ellas dependen los métodos auxiliares.
Error nº5: hacer que hashCode() dependa del id generado o de un campo mutable.
El id generado aparece demasiado tarde (después de persist() y/o flush), y un campo mutable puede cambiar en cualquier momento. En ambos casos, un objeto que ya está en una colección basada en hash se vuelve «invisible» para ella. Esto no es un error de HashSet, es un contrato: el hash debe ser estable. En nuestro contexto de Hibernate, «estable» casi siempre significa «no ligado a algo que pueda cambiar durante el ciclo de vida de la entidad».
GO TO FULL VERSION