1. Varias identidades de una entidad
Si antes escribía clases Java o DTO normales, la "identidad" se percibía simplemente: un objeto es un objeto, dos instancias diferentes significan dos objetos diferentes. En el mundo ORM, esta sensación comienza a fallar traicioneramente, porque una entidad es tanto un objeto Java en memoria como una representación de una fila en una tabla y una entidad de dominio (producto, cliente, pedido) que el negocio "reconoce" no por una referencia en la JVM.
Fijemos el problema de manera muy honesta con una pregunta sencilla: cuando decimos "este es el mismo Product", ¿qué queremos decir exactamente?
En Java se pueden tener dos objetos diferentes:
- están en diferentes lugares de memoria;
- tienen los mismos valores de campos;
- pueden haber sido leídos de la misma fila de la BD, pero en diferentes momentos y en diferentes transacciones;
- uno de ellos puede ser incluso un objeto proxy de Hibernate (es decir, un Product "disfrazado").
Y aquí es donde comienzan los "errores sutiles y costosos": colecciones, métodos auxiliares, orphanRemoval, duplicados inesperados, imposibilidad de eliminar un elemento de un Set, comportamiento extraño de contains(). No vamos a arreglar todo esto todavía (eso es para las lecciones 2–4), pero ahora es importante sentar las bases: la entidad vive en varios "espacios" a la vez, y cada espacio tiene su propia identidad.
2. Ejes de identidad: referencia, BD, clave de negocio
Para no confundirnos más adelante, es útil tener en mente tres "ejes" por los que se puede reconocer un objeto. Esto no es filosofía, sino un mapa práctico del terreno: explica por qué un mismo objeto de dominio puede comportarse como "diferente" — y viceversa.
Resumamos esto en una tabla compacta: no como una referencia para todos los casos, sino como un "mapa del terreno" para no perderse.
| Eje de identidad | Qué estamos comparando realmente | Cuándo "funciona bien" | Dónde rompe la intuición |
|---|---|---|---|
| Identidad referencial (Java) | el mismo objeto en memoria (==) | dentro de un mismo fragmento de código, cuando se tiene exactamente la misma referencia | tan pronto como el objeto se vuelve a leer o proviene de otra transacción |
| Identidad de fila de BD | el mismo registro de tabla (generalmente el mismo id) | cuando id ya existe y comparamos "¿qué fila?" | los objetos nuevos aún no tienen id; además, proxy e "instancias diferentes de una misma fila" |
| Identidad de negocio (clave de negocio) | atributo único de dominio (sku, email, orderNumber) | cuando la clave es realmente única y estable en el dominio | si la clave no es única o puede cambiar — se ha creado una trampa |
En nuestro proyecto Commerce Persistence Lab, estas claves de negocio están literalmente en la superficie:
- Product.sku — el producto se reconoce por SKU, no por su nombre "bonito".
- Customer.email — el cliente se reconoce por email (en el dominio de aprendizaje lo consideramos único).
- PurchaseOrder.orderNumber — el pedido en la comunicación empresarial se menciona por número, no por id.
Esta tabla no trata sobre "cómo escribir equals correctamente". Se trata de qué es lo que se considera "lo mismo". Y ya en la próxima lección comenzaremos a convertir esto en reglas para el código.
3. Identidad referencial: == y objetos diferentes
Es importante comenzar con la base más "aburrida", de lo contrario más adelante parecerá que Hibernate "rompió Java". No, Java está diseñado así: dos instancias de una clase son dos objetos, incluso si internamente tienen los mismos campos. Y Java no está obligado a considerarlos "iguales" — hasta que se indique explícitamente lo contrario a través de equals().
Mini-demo en Java puro, sin anotaciones ni Hibernate, para disipar la ilusión de que "todo esto es por culpa de JPA":
import java.util.Objects;
class ProductDraft {
// SKU es un atributo de dominio (para el ejemplo), pero aquí NO implementamos equals/hashCode
private final String sku;
ProductDraft(String sku) {
// Simplemente guardamos el valor: es un objeto Java normal sin magia ORM
this.sku = sku;
}
}
public class Demo {
public static void main(String[] args) {
// Dos instancias diferentes con los mismos datos internos
ProductDraft a = new ProductDraft("SKU-1");
ProductDraft b = new ProductDraft("SKU-1");
// Identidad referencial: son objetos diferentes en memoria
System.out.println(a == b); // false
// equals() por defecto (heredado de Object) también compara referencia
System.out.println(a.equals(b)); // false
}
}
Ambos objetos "son sobre el mismo SKU", pero referencialmente son entidades diferentes. El método equals() por defecto se hereda de Object y también compara referencias (básicamente, se comporta como ==). Para DTO, a menudo se genera equals() basado en campos — y aquí suele aparecer un hábito peligroso: "bueno, para la entidad haré lo mismo". No se apresure, luego Hibernate le mostrará dónde se esconden los rastrillos de esta idea.
4. Fila de BD y contexto de persistencia
Ahora pasamos a la parte de ORM. El pensamiento más desagradable para los principiantes suena así: la misma fila de la tabla puede estar representada por diferentes objetos Java. Y esto es normal. Es más, es inevitable tan pronto como se vive más de una transacción.
Dentro de una misma transacción, el contexto de persistencia funciona como un mapa de identidad: Hibernate se esfuerza para que un id corresponda a un solo objeto gestionado. Por lo tanto, dentro de una misma transacción, a menudo se ve un comportamiento "mágico": se lee dos veces y se obtiene el mismo objeto.
Aquí hay un ejemplo mínimo de servicio al estilo de nuestro proyecto:
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ProductIdentityDemoService {
// EntityManager es la puerta de entrada al contexto de persistencia de la transacción actual
private final EntityManager em;
public ProductIdentityDemoService(EntityManager em) {
this.em = em;
}
@Transactional
public void demoSameTx(Long productId) {
// Primer find: Hibernate carga la entidad y coloca el objeto gestionado en la caché de 1er nivel
var p1 = em.find(Product.class, productId);
// Segundo find en la misma transacción: devuelve la misma referencia desde el contexto de persistencia
var p2 = em.find(Product.class, productId);
// Dentro de una misma transacción, esto suele ser true (pero no es "identidad de dominio")
System.out.println(p1 == p2); // true
}
}
¿Por qué true? Porque el segundo find() encuentra la caché de primer nivel: Hibernate ya tiene el objeto gestionado para esa fila y devuelve la misma referencia.
Pero tan pronto como salimos de los límites de una transacción (y en la vida real casi siempre salimos, debido a peticiones HTTP, operaciones de servicio, etc.), la situación se vuelve opuesta: se vuelve a leer la misma fila, pero se obtiene una nueva instancia Java.
Incluso si es "el mismo producto" en la BD, == será false, porque es una referencia diferente.
Mentalmente, es útil dibujarlo así:
flowchart TD
%% Una misma fila en la BD puede estar representada por diferentes objetos en diferentes transacciones
DB["product row id=10, sku=SKU-10"]
TX1["Transacción #1 Contexto de persistencia #1"]
TX2["Transacción #2 Contexto de persistencia #2"]
P1["Java object Product@A"]
P2["Java object Product@B"]
DB --> TX1 --> P1
DB --> TX2 --> P2
Una fila — dos objetos. Y esto no es un error. Es simplemente una consecuencia de que el contexto de persistencia vive dentro de la transacción, no es "eterno".
El significado práctico es muy simple: si en el código se intenta saber "si es el mismo producto" usando ==, no se está comprobando el producto, sino un hecho aleatorio: "¿es la misma referencia justo ahora?". Esto a veces coincide con la realidad del dominio (dentro de un mismo contexto), pero como criterio de identidad del producto, es inútil.
5. id en entidades nuevas: null
Llegados a este punto, muchos dicen: "Vale, no voy a comparar referencias. Compararé el id — porque es el identificador de la fila". Y en general, el pensamiento es lógico… hasta que se recuerda una cosa del día sobre estados de entidades: un objeto nuevo aún no tiene id.
Hasta que la entidad se convierta en una "fila en la BD" (y no solo un objeto en memoria), su identidad de BD no existe. Por lo tanto, en todos los lugares donde se trabaja con objetos nuevos (creación de pedido, adición de líneas, creación de ProductCategoryAssignment), la comparación "por id" puede resultar ser una comparación "por null".
Ejemplo mínimo ilustrativo: creamos una entidad (aún transient), y id está lógicamente vacío:
import java.math.BigDecimal;
public class CreateProductDemo {
public static void main(String[] args) {
// Creamos un nuevo objeto en memoria: aún no es una "fila en la BD"
Product p = new Product("SKU-NEW", "Coffee mug");
// Antes de persist/flush, id normalmente no está asignado (a menudo null)
System.out.println(p.getId()); // null
}
}
Aquí no importa el modelo específico del constructor (en una entidad real los constructores pueden ser diferentes), sino el hecho: antes de guardar en la BD, el campo id suele ser null. Por lo tanto, intentar "reconocer la entidad" por id antes de persistir es como intentar reconocer a personas por el número de pasaporte que aún no se les ha emitido. A veces se acierta, pero no es una estrategia, sino suerte.
Y he aquí por qué la lección de hoy (sobre identidad) va antes de las lecciones sobre equals() y hashCode(). Si se empieza a escribir la igualdad "por id" sin entender este matiz, se obtendrán efectos muy extraños en las colecciones: dos objetos nuevos diferentes con id == null pueden de repente "convertirse en el mismo" desde el punto de vista de la lógica de comparación. Y eso ya es un puente directo a errores en Set y métodos auxiliares (siguientes lecciones del día).
6. Clave de negocio e identidad de dominio
Ahora, el concepto más importante de la lección de hoy: clave de negocio (business key). Es un atributo mediante el cual el dominio reconoce un objeto como "el mismo", incluso si el objeto se ha vuelto a leer, serializado, separado, obtenido en otro servicio y, en general, ha sobrevivido a todas las aventuras de un héroe de RPG.
El criterio es simple y severo: la clave de negocio debe ser única y estable.
En nuestro dominio, estos atributos se ven naturales:
- el producto se reconoce por sku;
- el cliente — por email;
- el pedido — por orderNumber.
Comparemos dos campos de un producto para sentir la diferencia:
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
@Entity
public class Product {
@Id
private Long id;
@Column(nullable = false, unique = true)
private String sku; // buen candidato para clave de negocio: único y (idealmente) inmutable
@Column(nullable = false)
private String name; // mal candidato: el nombre cambia fácilmente y no garantiza unicidad
}
La idea aquí es: name es lo que es conveniente mostrar al usuario, mientras que sku es lo que "reconoce" al producto como entidad. El nombre puede cambiar ("Taza" → "Taza grande"), y eso no debería convertir al producto en un producto "diferente". Y el SKU, dentro del curso, lo consideramos inmutable.
Una lógica similar se aplica al cliente:
- el nombre y apellido pueden corregirse (y es normal),
- el email — en nuestro dominio de aprendizaje es único y se utiliza como identificador del cliente.
Y en un pedido, el número de pedido es, de hecho, el "nombre oficial" del pedido en los procesos de negocio. Las personas rara vez dicen "pedido con id=912", pero a menudo dicen "pedido n.º PO-2026-000123".
Hay un detalle práctico importante: una clave de negocio es buena solo cuando está fijada como única no solo en Java, sino también en el esquema de la BD. Por eso, en las migraciones de Flyway para el proyecto, es natural tener restricciones de unicidad. En su forma mínima, se ve así:
-- Fijamos la unicidad de negocio a nivel de BD, para que sea una regla, no una "esperanza"
alter table product
add constraint uk_product_sku unique (sku);
Sin tal restricción, la clave de negocio se convierte en una "esperanza". Y la esperanza es una mala herramienta para la capa de persistencia. Hibernate es muy bueno en SQL, pero muy malo para "esperar".
Mini-resumen: qué aporta esto más adelante
Ahora uno quiere saltar directamente a equals() y escribir la implementación "correcta". Pero si se hace demasiado pronto, se estará tratando el síntoma sin entender el diagnóstico. El objetivo de hoy es que en la cabeza aparezca una imagen: la entidad tiene al menos tres "caras".
En el código, se pueden comparar accidentalmente:
- referencias (y entonces se compara "si es el mismo objeto en memoria");
- id (y entonces se compara "si es la misma fila de BD", pero solo si id ya existe);
- clave de negocio (y entonces se compara "si es la misma entidad de dominio", si la clave es estable y única).
Más adelante, según el plan del día, convertiremos esta imagen en reglas: cómo elegir la base para equals()/hashCode(), por qué "igualdad por todos los campos" es una trampa, cómo el proxy rompe getClass(), y por qué Set es el primero en señalar que su igualdad no es ingenieril, sino optimista.
7. Errores típicos en la comprensión de la identidad de las entidades
Error n.º 1: pensar que "los mismos campos" significan "la misma entidad".
Esta es la intuición de DTO, y a menudo es útil para "instantáneas de datos". Pero una entidad no es una instantánea, sino un objeto con un ciclo de vida que refleja una fila de BD y una entidad de dominio. Dos objetos pueden tener los mismos campos ahora, pero ser entidades diferentes. Y viceversa: un mismo producto puede cambiar de nombre, precio, estado — y seguir siendo el mismo producto.
Error n.º 2: comparar entidades con == y esperar que Hibernate "lo entienda de todos modos".
== solo comprueba la referencia. Dentro de una misma transacción, a veces se obtendrá la respuesta "correcta" por casualidad, porque el contexto de persistencia devuelve el mismo objeto gestionado. Pero tan pronto como se cruza el límite de la transacción, == deja de tener cualquier significado de dominio.
Error n.º 3: asumir que id siempre existe y siempre sirve como "pasaporte de la entidad".
En objetos nuevos, id a menudo es null. Por lo tanto, si se empieza a construir la lógica de identidad alrededor de id demasiado pronto, inevitablemente se encontrará con que "objetos nuevos diferentes son iguales" (porque ambos tienen null). Esto duele especialmente en colecciones y métodos auxiliares, pero la raíz del problema es precisamente un modelo de identidad incorrecto.
Error n.º 4: declarar como clave de negocio un campo que puede cambiar o no garantiza unicidad.
"Nombre del producto" o "apellido del cliente" suenan humanos, pero no son únicos y cambian fácilmente. Si se incluyen tales campos en el concepto de identidad, cualquier corrección de texto convierte para el sistema en que "apareció un nuevo objeto", y el antiguo como que desapareció. En un sistema real, esto no es solo un error, es magia financiera (generalmente mala).
Error n.º 5: no fijar la clave de negocio a nivel del esquema de la BD.
Si no hay una restricción de unicidad en la tabla, incluso el código Java más bonito puede resultar impotente: la base de datos permitirá crear dos productos con el mismo SKU. Y entonces la "clave de negocio" dejará de ser una clave y se convertirá en un motivo para leer registros por la noche y reflexiones filosóficas sobre el sentido de la vida de las transacciones.
GO TO FULL VERSION