1. Clave compuesta: significado y razones
Ya diferenciamos PK técnica y business-id, pero todavía no es suficiente: a veces la fila no se define por un solo campo, sino por una combinación de valores. Si estás acostumbrado a que cada tabla tenga «una columna id y punto», la clave compuesta al principio parece una ocurrencia extraña de personas a las que les gusta complicar. Pero en los datos reales a veces hay entidades que no tienen sentido de tener un surrogate id artificial, porque la fila ya es única por la combinación de atributos naturales. Es como una dirección: una «casa» no se identifica con un solo número, sino con un conjunto “ciudad + calle + número”.
Una clave compuesta (composite key) significa que la primary key de una fila son varios campos simultáneamente. Por ejemplo, para una instantánea de existencias en un almacén tiene sentido decir: «la instantánea se identifica por el producto y la fecha de la instantánea». Si tienes product_id=10 y snapshot_date=2026-03-18, entonces no debería existir una segunda fila igual — de lo contrario ya no es una “instantánea”, sino una realidad paralela.
En Hibernate/JPA esta elección afecta directamente al comportamiento en tiempo de ejecución. El Persistence context almacena un identity map (lo discutimos antes), y la clave allí es la combinación “tipo de entidad + valor de id”. Si el id es complejo, entonces la “clave” en el identity map se convierte en un objeto complejo que debe comportarse de manera predecible: equals()/hashCode() correctos, estabilidad de valores, ausencia de campos «flotantes».
Para fijar la idea, es útil representarlo así:
flowchart TD
A["Instancia de entidad (InventorySnapshot)"] --> B["Valor de @Id"]
B --> C["Clave del identity map en Persistence Context"]
C --> D["¿Hibernate entiende que es la misma fila?"]
Si @Id es un Long, hay pocas dudas. Si @Id es un objeto de dos campos, surgen más preguntas y debemos responderlas en el código.
2. Ejemplo: InventorySnapshot — producto y fecha
Para que la clave compuesta no sea una teoría abstracta, la vincularemos con nuestro laboratorio Commerce Persistence Lab. En el proyecto hay una entidad advanced-lab InventorySnapshot: almacena valores históricos de existencias por producto. La idea clave es: para un producto en una fecha debe existir como máximo una instantánea — de lo contrario no sabríamos cuál es «la verdadera».
Es decir, la identidad de la fila se puede expresar como:
- productId — qué producto
- snapshotDate — de qué fecha es la instantánea
Y esto se ve muy honesto como primary key. No inventamos un snapshot_id artificial que por sí mismo no significa nada. Decimos: «la instantánea es (producto, fecha)». Exactamente como el negocio piensa sobre este objeto.
Es importante no confundir la clave compuesta con @NaturalId. Natural id es un campo de negocio único (o conjunto de campos) para búsqueda, pero no tiene que ser primary key. La clave compuesta ya es un nivel de esquema e identidad de la fila. En el código práctico se nota inmediatamente: findById(...) ya no acepta un solo número. Solicita un objeto-clave completo.
3. Clave compuesta en JPA: @EmbeddedId y @IdClass
JPA no permite simplemente escribir «tengo dos @Id y todo funciona solo». Formalmente permite varios @Id, pero obliga a definir una clase de identificador que describa la clave compuesta. Para ello hay dos variantes principales: @EmbeddedId y @IdClass. Ambas resuelven la misma tarea, pero lo hacen de diferente manera y, posteriormente, afectan de distinta forma la legibilidad, la API y el mapeo.
A continuación, una tabla compacta para que veas de inmediato qué comparamos. No intentes memorizarla: sirve para que el cerebro entienda que las diferencias son reales, no «dos anotaciones iguales».
| Criterio | @EmbeddedId | @IdClass |
|---|---|---|
| Dónde está el id en la entidad | En un campo-objeto id | Los campos id están directamente en la entidad |
| Cómo se ve findById | findById(new Key(...)) | Lo mismo, pero la entidad es «plana» |
| Qué se parece en estilo | Value object (ya hemos usado @Embeddable) | «Entidad plana + clase separada para la clave» |
| Cuándo es más fácil hacerlo con cuidado | Cuando la clave se percibe lógicamente como un valor único | Cuando es cómodo tener los campos de la clave directamente en la entidad |
| Cuándo se encuentran más bugs extraños | Si se olvidan equals()/hashCode() en la clase id | Si se confunde la correspondencia de campos entre la entidad y la clase clave |
A continuación veremos ambas variantes con el ejemplo de InventorySnapshot, para que veas la diferencia no de oídas, sino en código.
4. Reglas de la clase de id compuesto
Antes de escribir anotaciones, es importante ponernos de acuerdo sobre las reglas del juego. La clave compuesta es un caso en el que una clase pequeña se convierte de repente en el fundamento del identity map dentro de Hibernate. Por lo tanto, los requisitos para la clase id no son una «formalidad para marcar la casilla», sino una condición de corrección.
En JPA, la clase id debe ser pública, tener un constructor sin argumentos (Hibernate/JPA la crearán mediante reflexión), ser Serializable, y tener equals()/hashCode() correctos. En nuestro curso esto suena conocido: en el nivel sobre equals/hashCode ya discutimos que la «igualdad» no es un tema decorativo, sino una fuente de bugs reales en colecciones y en ORM.
Otra idea: los campos de la clave compuesta deben ser estables. Si incluiste en la clave un campo que puede cambiar, casi con seguridad caerás en la situación de «cambio de clave = cambio de identidad de la fila». Es como tratar de cambiar el número de pasaporte a una persona y esperar que siga siendo la misma persona. Formalmente no, y en la práctica es aún peor.
5. Variante 1: @EmbeddedId
@EmbeddedId es la forma más «value-object» de describir una clave compuesta. Y es una continuación lógica del nivel 14 sobre @Embeddable: ya sabemos incrustar valores, ahora incrustamos el identificador. Como resultado, la entidad obtiene un único campo id que contiene las partes de la clave. La ventaja de este enfoque es que la clave se puede percibir como un objeto único: pasarlo a un método, guardarlo en una variable, imprimirlo en el log.
En la práctica, esto suele leerse más agradablemente porque ves explícitamente: InventorySnapshot no se identifica por «dos columnas aleatorias», sino por el objeto InventorySnapshotId. Pero también la responsabilidad es mayor: este objeto debe ser perfectamente correcto en cuanto a igualdad, porque Hibernate se basará en él.
InventorySnapshotId como @Embeddable
Empecemos con la clase id. En nuestro dominio, se compone de productId y snapshotDate. Observa Serializable, el constructor sin argumentos y equals/hashCode. Sí, es aburrido. Sí, es necesario. Es como el cinturón de seguridad: no por estética.
import jakarta.persistence.Embeddable;
import java.io.Serializable;
import java.time.LocalDate;
import java.util.Objects;
@Embeddable
public class InventorySnapshotId implements Serializable {
// Parte de la clave compuesta: identificador del producto
private Long productId;
// Parte de la clave compuesta: fecha en la que se tomó la instantánea
private LocalDate snapshotDate;
// Necesario para que JPA/Hibernate cree el objeto mediante reflexión
protected InventorySnapshotId() { }
// Constructor cómodo para el código de aplicación (servicios/pruebas)
public InventorySnapshotId(Long productId, LocalDate snapshotDate) {
this.productId = productId;
this.snapshotDate = snapshotDate;
}
}
Ahora agregamos equals()/hashCode(). Lo pondré en un bloque pequeño aparte para no alargar el ejemplo.
@Override
public boolean equals(Object o) {
if (this == o) return true;
// Importante: comparamos por tipo y por valores de los campos de la clave
if (!(o instanceof InventorySnapshotId that)) return false;
// Igualdad de claves = igualdad de ambas partes (producto + fecha)
return Objects.equals(productId, that.productId)
&& Objects.equals(snapshotDate, that.snapshotDate);
}
@Override
public int hashCode() {
// hashCode debe depender de los mismos campos que equals()
return Objects.hash(productId, snapshotDate);
}
Aquí comparamos deliberadamente ambos campos. Y se ve exactamente como esperamos: dos claves son iguales si coinciden producto y fecha.
Entidad InventorySnapshot con @EmbeddedId
Ahora la entidad misma. Con @EmbeddedId se ve muy directa: tenemos un campo id y un campo quantity. Para un ejemplo didáctico es suficiente, y de momento no complicamos con la relación con Product.
import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
@Entity
@Table(name = "inventory_snapshot")
public class InventorySnapshot {
// Toda la identidad de la fila vive aquí, como un value-object único
@EmbeddedId
private InventorySnapshotId id;
// Atributo normal, no parte de la identidad
private Integer quantity;
// Necesario para JPA/Hibernate
protected InventorySnapshot() { }
}
Y añadimos un mini-constructor para crear instantáneas cómodamente en el servicio. Lo mantenemos corto.
public InventorySnapshot(InventorySnapshotId id, Integer quantity) {
this.id = id;
this.quantity = quantity;
}
Observa algo importante: con clave compuesta, casi siempre es obligatorio tener el id completamente formado antes de persist(). No existe «luego lo generamos». Esto no es SEQUENCE, ni UUID, ni IDENTITY. Es un id asignado, solo que compuesto.
Repositorio Spring Data
El repositorio cambia literalmente en un lugar: el segundo parámetro genérico de JpaRepository ahora no es Long, sino InventorySnapshotId.
import org.springframework.data.jpa.repository.JpaRepository;
// Importante: el tipo de id es una clase separada (clave compuesta), no Long
public interface InventorySnapshotRepository
extends JpaRepository<InventorySnapshot, InventorySnapshotId> {
}
Y esto ya afecta a todo el código cliente: findById ahora recibe un objeto clave.
Uso: guardar y leer una instantánea
Mostremos un mini-escenario de un método de servicio. Lo haré deliberadamente «con los dedos»: formamos el id, creamos la entidad, guardamos, leemos por id.
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
@Service
public class InventorySnapshotService {
private final InventorySnapshotRepository repository;
public InventorySnapshotService(InventorySnapshotRepository repository) {
// Inyectamos el repositorio que trabaja con composite-id
this.repository = repository;
}
@Transactional
public void saveSnapshot(long productId, LocalDate date, int qty) {
// La clave compuesta debe estar completamente formada antes de save/persist
var id = new InventorySnapshotId(productId, date);
// Guardamos la instantánea: identity = (productId, date)
repository.save(new InventorySnapshot(id, qty));
}
}
Aquí reaparece nuestro modelo del inicio del curso: la transacción define la unit of work, el persistence context sabe qué es una managed-entity, y el id es lo que vincula el objeto con la fila. Con una clave compuesta, simplemente estás obligado a tratar el id como un objeto de modelo de pleno derecho, no como un «número por ahí».
6. Variante 2: @IdClass
@IdClass es otro estilo del mismo modelo. Para InventorySnapshot en el proyecto se queda como una alternativa para comparar, no como una nueva línea base: consideramos que @EmbeddedId es la variante de trabajo. Pero @IdClass igualmente es importante saber leerla, porque aparece regularmente en legacy y en código ajeno. Les gusta a las personas que quieren ver los campos de la clave directamente en la entidad, sin el envoltorio id. Desde el punto de vista de la lectura del código, a veces es realmente más cómodo: abres la entidad y ves directamente productId y snapshotDate. Pero JPA igualmente exige una clase clave separada, porque en algún lado debe existir un «tipo de id» para findById().
Es importante recordar: con @IdClass, los campos id en la clase clave deben coincidir en nombres y tipos con los campos @Id de la entidad. Y aquí es donde comienzan los errores «silenciosos»: un error tipográfico en el nombre, un tipo diferente (Long vs long), y ya estás atrapando rarezas al iniciar la aplicación.
Clase clave para @IdClass
Primero hacemos una clase clave separada. Se parece a InventorySnapshotId, pero sin @Embeddable (aquí no se necesita). Los requisitos son los mismos: Serializable, constructor sin argumentos, equals/hashCode.
import java.io.Serializable;
import java.time.LocalDate;
import java.util.Objects;
public class InventorySnapshotKey implements Serializable {
// Debe coincidir en nombre y tipo con el campo @Id de la entidad
private Long productId;
// Debe coincidir en nombre y tipo con el campo @Id de la entidad
private LocalDate snapshotDate;
// Necesario para JPA/Hibernate
public InventorySnapshotKey() { }
// Constructor cómodo para el código de aplicación
public InventorySnapshotKey(Long productId, LocalDate snapshotDate) {
this.productId = productId;
this.snapshotDate = snapshotDate;
}
}
Y de nuevo añadimos la igualdad. No porque «esté bien visto», sino porque afecta a la identidad.
@Override
public boolean equals(Object o) {
if (this == o) return true;
// Comparamos las claves por valores: esto afecta al identity map en el persistence context
if (!(o instanceof InventorySnapshotKey that)) return false;
return Objects.equals(productId, that.productId)
&& Objects.equals(snapshotDate, that.snapshotDate);
}
@Override
public int hashCode() {
// Los mismos campos que en equals(), de lo contrario habrá «fantasmas» en HashMap/Set
return Objects.hash(productId, snapshotDate);
}
Entidad con @IdClass
Ahora la entidad. Aquí los campos clave están directamente en ella y se marcan con @Id. Y arriba se coloca @IdClass(InventorySnapshotKey.class).
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
import jakarta.persistence.Table;
import java.time.LocalDate;
@Entity
@Table(name = "inventory_snapshot")
@IdClass(InventorySnapshotKey.class) // Le decimos a JPA: "mi id se describe con esta clase"
public class InventorySnapshotFlat {
// Parte de la identidad (PK)
@Id
private Long productId;
// Parte de la identidad (PK)
@Id
private LocalDate snapshotDate;
// Atributo normal, no parte de la PK
private Integer quantity;
}
La ventaja de @IdClass es que no tienes que arrastrar snapshot.getId().getProductId() — simplemente tienes snapshot.getProductId(). La desventaja es que la clave está como «esparcida» por la entidad, y debes recordar constantemente que estos dos campos son especiales, no son simples atributos, son la identidad.
Repositorio
El repositorio se ve casi igual que con @EmbeddedId, solo que la clase clave es otra y la entidad es otra.
import org.springframework.data.jpa.repository.JpaRepository;
public interface InventorySnapshotFlatRepository
extends JpaRepository<InventorySnapshotFlat, InventorySnapshotKey> {
}
Sí, findById igualmente espera un InventorySnapshotKey. Es decir, desde el punto de vista de la API del consumidor no ganas «un campo en lugar de un objeto». Ganas legibilidad de los campos dentro de la propia entidad.
7. Elección: @EmbeddedId o @IdClass
Cuando te encuentras por primera vez con dos formas de hacer lo mismo, quieres preguntar: «¿cuál es la correcta?». Para JPA en general no hay un ganador universal, pero para nuestro proyecto la elección se puede fijar de manera más estricta: InventorySnapshot lo dejamos con @EmbeddedId. En esta entidad, la clave realmente vive como un único valor (productId, snapshotDate), y esa variante se lee mejor en servicios, pruebas y findById(). @IdClass se queda al lado como una herramienta JPA normal que hay que saber leer y evaluar, pero no como una segunda instantánea paralela de la misma entidad.
Si la clave compuesta se percibe como un objeto semántico único, normalmente @EmbeddedId se lee más fácil. En InventorySnapshot parece cierto: «el identificador de la instantánea» es una cosa aparte (producto, fecha). Además, @EmbeddedId combina bien con lo que ya hemos discutido: value objects y @Embeddable. Obtienes un objeto único que puedes pasar a findById().
Si prefieres una entidad lo más «plana» posible y te gusta ver las partes del id directamente en ella, @IdClass puede ser más cómodo. Pero por la comodidad pagas con disciplina: los nombres y tipos deben coincidir, y cualquier error pequeño se convierte en un molesto bug de arranque.
Hay también otro factor práctico: cómo vas a mapear la relación con Product. Para @EmbeddedId, el camino típico es @MapsId, y es bastante natural. Para @IdClass, la relación también es posible, pero a menudo conduce a un mapeo más «ruidoso» (por ejemplo, duplicación de columnas o insertable=false, updatable=false). En el ámbito de esta lección no entraremos en variantes complejas, pero es importante saber que la elección de la anotación afecta no solo a la estética, sino también a las posibilidades de mapeo.
8. Migración Flyway con clave compuesta
Es muy fácil escribir anotaciones y olvidar que al final todo se apoya en la tabla. Pero según las reglas del proyecto, el esquema vive solo a través de Flyway, y eso es una buena disciplina: siempre ves lo que realmente se almacena en la base de datos. Para una clave compuesta esto es especialmente útil, porque la primary key en la BD también se vuelve compuesta.
Ejemplo de una migración mínima (acortada) para que tengamos la tabla inventory_snapshot. Aquí la clave primaria es (product_id, snapshot_date):
create table inventory_snapshot (
product_id bigint not null, -- parte de la PK compuesta: producto
snapshot_date date not null, -- parte de la PK compuesta: fecha de la instantánea
quantity integer not null, -- atributo de la instantánea
primary key (product_id, snapshot_date) -- clave primaria compuesta
);
Si agregas una relación con product, en un proyecto real añadirías también una clave foránea a la tabla de productos. Pero ahora nos interesa fijar solo el principio: la clave compuesta no es solo un «objeto Java», sino también una construcción compuesta real del lado de la BD.
9. Errores típicos al trabajar con @EmbeddedId y @IdClass
Error nº1: olvidar equals() / hashCode() en la clase id.
Este es uno de los errores más «costosos», porque puede manifestarse de forma extraña: a veces findById no encuentra, a veces el persistence context empieza a comportarse de manera impredecible, a veces las pruebas se vuelven «flaky» sin razón aparente. El id compuesto participa en la identidad, por lo que la igualdad correcta no es una opción, sino una parte obligatoria del contrato.
Error nº2: hacer la clase id mutable y luego comenzar a modificar sus campos.
En una clave compuesta, los campos deben ser estables. Si cambias snapshotDate de InventorySnapshotId, en la práctica estás «renombrando» la clave primaria de la fila. En el mundo de objetos parece «corregí un poco la fecha», pero en el mundo de la BD ya es «otra fila». A Hibernate no le gusta esa vida, y a ti tampoco te gustará cuando veas el resultado en SQL y en las excepciones.
Error nº3: con @IdClass no coinciden los nombres/tipos de los campos entre la entidad y la clase clave.
Esto es un clásico: en la entidad private Long productId;, y en la clase clave private long productId; o el campo se llamó productID (con D mayúscula) y piensas que «Java lo va a entender». Java lo entenderá, pero JPA no. Y obtendrás problemas que parecen «Hibernate se rompió», cuando en realidad se rompió la disciplina de correspondencia.
Error nº4: intentar «generar» parcialmente la clave compuesta.
A veces se desea: «que productId se tome de la referencia a Product, y la fecha no la pongo, ya se pondrá después». Para un id compuesto, casi siempre es una mala idea. La clave compuesta debe estar completamente definida antes de persist(). De lo contrario, obtienes una excepción, o un registro con una clave inesperada, o un montón de lógica implícita que luego es difícil de explicar en una revisión de código.
Error nº5: elegir clave compuesta solo porque «está más cerca del modelo relacional».
La clave compuesta se justifica cuando realmente expresa la identidad natural de la fila. Si la entidad tiene su propio ciclo de vida, otras tablas la referenciarán, y no quieres arrastrar dos campos como clave foránea a todas partes, entonces un surrogate id técnico puede ser más simple. La clave compuesta es una herramienta, no un estilo de vida.
GO TO FULL VERSION