1. Composite key: meaning and reasons
We have already separated the technical PK and the business id, but that is still not enough: sometimes the row itself is genuinely defined not by one field, but by a combination of values. If you are used to the idea that every table has “one id column and that is it,” a composite key first looks like a strange whim of people who enjoy making things more complicated. But in real data there are sometimes entities where it does not make sense to invent a separate surrogate id, because the row is already unique by a combination of natural attributes. It is like an address: one “house” is identified not by a single number, but by a set of “city + street + house number”.
A composite key (composite key) means that the primary key of a row is several fields at once. For example, for a warehouse stock snapshot it makes sense to say: “the snapshot is identified by the product and the snapshot date.” If you have product_id=10 and snapshot_date=2026-03-18, then there should not be a second such row — otherwise it is no longer a “snapshot,” but some parallel reality.
In Hibernate/JPA, this choice directly affects runtime behavior. The Persistence context stores an identity map (we discussed this earlier), and its key is the combination of “entity type + id value.” If the id is complex, then the “key” in the identity map also becomes a complex object, and it must behave predictably: correct equals()/hashCode(), stable values, and no “floating” fields.
To fix the picture, it is convenient to imagine it like this:
flowchart TD
A["Entity instance (InventorySnapshot)"] --> B["@Id value"]
B --> C["Persistence Context identity map key"]
C --> D["Hibernate understands: is this the same row?"]
If @Id is a Long, there are few questions. If @Id is an object made of two fields, there are more questions, and we have to answer them in code.
2. Example: InventorySnapshot — product and date
To keep the composite key from being abstract theory, let us tie it to our Commerce Persistence Lab laboratory. The project has the advanced-lab entity InventorySnapshot: it stores historical stock values by product. The key idea is this: for one product on one date, there should be at most one snapshot — otherwise we do not know which one is “true.”
So the row identity can be expressed as:
- productId — which product
- snapshotDate — which date the snapshot is for
And that looks very honest as a primary key. We are not inventing an artificial snapshot_id that means nothing by itself. We are saying: “a snapshot is (product, date).” Exactly the way the business thinks about this object.
It is important not to confuse a composite key with @NaturalId. A natural id is a unique business field (or set of fields) for lookup, but it does not have to be the primary key. A composite key is already the level of schema and row identity. In practical code you feel this immediately: findById(...) no longer takes a single number. It asks for an entire key object.
3. Composite key in JPA: @EmbeddedId and @IdClass
JPA does not let you simply write “I have two @Id fields and everything just works.” Formally, it does allow multiple @Id fields, but it definitely asks you to define an identifier class that describes the composite key. There are two main ways to do that: @EmbeddedId and @IdClass. Both solve the same task, but they do it in different ways, and later they affect readability, API, and mapping differently.
Below is a compact table so you can see what we are comparing right away. Do not try to memorize it by heart: it is there so your brain understands that the differences are real, not “two identical annotations.”
| Criterion | @EmbeddedId | @IdClass |
|---|---|---|
| Where the id lives in the entity | In one object field id | Id fields live directly in the entity |
| What findById looks like | findById(new Key(...)) | The same, but the entity is “flat” |
| What it resembles in style | Value object (we already did @Embeddable) | “Flat entity + separate class for the key” |
| Where it is often easier to do cleanly | When the key is naturally perceived as a single value | When it is convenient to keep the key fields directly on the entity |
| Where strange bugs are more often caught | If equals()/hashCode() are forgotten on the id class | If the correspondence between entity and key class fields is mixed up |
We will go through both variants below using InventorySnapshot so you can see the difference not by story, but by code.
4. Rules for a composite id class
Before writing annotations, it is important to agree on the rules of the game. A composite key is a case where a small class suddenly becomes the foundation of the identity map inside Hibernate. Therefore, the requirements for the id class are not a “formality for the checklist,” but a condition of correctness.
In JPA, the id class must be public, have a no-argument constructor (Hibernate/JPA will create it via reflection), be Serializable, and have correct equals()/hashCode(). In our course, this sounds familiar: in the lesson on equals/hashCode, we already discussed that “equality” is not a decorative topic, but a source of real bugs in collections and in ORM.
Another point: the fields of a composite key must be stable. If you include a field in the key that can change, you are almost certainly entering the situation “changing the key = changing the row identity.” It is like trying to change a person’s passport number and expecting them to remain the same person. Formally — no, practically — even worse.
5. Option 1: @EmbeddedId
@EmbeddedId is the most “value-object” way to describe a composite key. And it logically continues level 14 about @Embeddable: we already know how to embed values, now we embed an identifier. As a result, the entity gets one id field, inside which the key parts live. The advantage of this approach is that the key can be perceived as a single object: pass it to a method, store it in a variable, print it in a log.
In practice, this often reads more pleasantly because you can clearly see that InventorySnapshot is identified not by “two random columns,” but by the object InventorySnapshotId. But the responsibility is also higher: this object must be perfectly correct for equality, because Hibernate will rely on it.
InventorySnapshotId as @Embeddable
Let us start with the id class. In our domain model, it consists of productId and snapshotDate. Pay attention to Serializable, the no-arg constructor, and equals/hashCode. Yes, it is boring. Yes, it is necessary. It is like a seat belt: not for beauty.
import jakarta.persistence.Embeddable;
import java.io.Serializable;
import java.time.LocalDate;
import java.util.Objects;
@Embeddable
public class InventorySnapshotId implements Serializable {
// Part of the composite key: product identifier
private Long productId;
// Part of the composite key: the date for which the snapshot is recorded
private LocalDate snapshotDate;
// Needed by JPA/Hibernate to create the object via reflection
protected InventorySnapshotId() { }
// Convenient constructor for application code (services/tests)
public InventorySnapshotId(Long productId, LocalDate snapshotDate) {
this.productId = productId;
this.snapshotDate = snapshotDate;
}
}
Now let us add equals()/hashCode(). I will put it in a separate small block so the example does not turn into a wall of text.
@Override
public boolean equals(Object o) {
if (this == o) return true;
// Important: compare by the type and values of the key fields
if (!(o instanceof InventorySnapshotId that)) return false;
// Key equality = equality of both parts (product + date)
return Objects.equals(productId, that.productId)
&& Objects.equals(snapshotDate, that.snapshotDate);
}
@Override
public int hashCode() {
// hashCode must depend on the same fields as equals()
return Objects.hash(productId, snapshotDate);
}
Here we deliberately compare both fields. And that looks exactly as expected: two keys are equal if the product and the date match.
Entity InventorySnapshot with @EmbeddedId
Now the entity itself. With @EmbeddedId it looks very direct: there is an id field, and there is a quantity field. For a teaching example, that is enough, and we will not complicate the relation to Product yet.
import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
@Entity
@Table(name = "inventory_snapshot")
public class InventorySnapshot {
// The entire row identity lives here, as a single value object
@EmbeddedId
private InventorySnapshotId id;
// A regular attribute, not part of identity
private Integer quantity;
// Needed by JPA/Hibernate
protected InventorySnapshot() { }
}
And let us add a small constructor so snapshots are easy to create in a service. We will keep it short.
public InventorySnapshot(InventorySnapshotId id, Integer quantity) {
this.id = id;
this.quantity = quantity;
}
Notice an important thing: with a composite key, you almost always must have the id fully formed before persist(). There is no “we will generate it later.” This is not SEQUENCE, not UUID, not IDENTITY. This is an assigned id, only composite.
Spring Data repository
The repository changes literally in one place: the second generic parameter of JpaRepository is now not Long, but InventorySnapshotId.
import org.springframework.data.jpa.repository.JpaRepository;
// Important: the id type is a separate class (composite key), not Long
public interface InventorySnapshotRepository
extends JpaRepository<InventorySnapshot, InventorySnapshotId> {
}
And that already affects all calling code: findById now takes the key object.
Usage: save and read a snapshot
Let us show a mini scenario from a service method. I will intentionally make it as “plain language” as possible: we formed the id, created the entity, saved it, and read it back by 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) {
// Inject the repository that works with a composite id
this.repository = repository;
}
@Transactional
public void saveSnapshot(long productId, LocalDate date, int qty) {
// The composite key must be fully formed before save/persist
var id = new InventorySnapshotId(productId, date);
// Save the snapshot: identity = (productId, date)
repository.save(new InventorySnapshot(id, qty));
}
}
Here our model from the beginning of the course comes up again: the transaction defines the unit of work, the persistence context knows what a managed entity is, and the id is what binds the object and the row together. With a composite key, you simply must treat the id as a full-fledged model object, not as “some number somewhere.”
6. Option 2: @IdClass
@IdClass is another style of the same model. For InventorySnapshot in the project, it remains an alternative for comparison, not a new baseline: we consider @EmbeddedId the working option. But @IdClass is still important to be able to read, because it appears regularly in legacy code and in other people’s code. People like it when they want to see the key fields directly on the entity, without the id wrapper. From the point of view of reading code, that is sometimes indeed more convenient: open the entity and immediately see productId and snapshotDate. But JPA still asks for a separate key class, because somewhere there must exist an “id type” for findById().
Remember: with @IdClass, the id fields in the key class must match the names and types of the @Id fields on the entity. And this is where “silent” errors begin: a typo in the name, a different type (Long vs long), and you run into oddities already at application startup.
Key class for @IdClass
First, we create a separate key class. It is similar to InventorySnapshotId, but without @Embeddable (it is not needed here). The requirements are the same: Serializable, no-arg constructor, equals/hashCode.
import java.io.Serializable;
import java.time.LocalDate;
import java.util.Objects;
public class InventorySnapshotKey implements Serializable {
// Must match the name and type of the @Id field in the entity
private Long productId;
// Must match the name and type of the @Id field in the entity
private LocalDate snapshotDate;
// Needed by JPA/Hibernate
public InventorySnapshotKey() { }
// Convenient constructor for application code
public InventorySnapshotKey(Long productId, LocalDate snapshotDate) {
this.productId = productId;
this.snapshotDate = snapshotDate;
}
}
And again we add equality. Not because “that is the convention,” but because it affects identity.
@Override
public boolean equals(Object o) {
if (this == o) return true;
// Compare keys by value: this affects the identity map in the persistence context
if (!(o instanceof InventorySnapshotKey that)) return false;
return Objects.equals(productId, that.productId)
&& Objects.equals(snapshotDate, that.snapshotDate);
}
@Override
public int hashCode() {
// The same fields as in equals(), otherwise you will get "ghosts" in HashMap/Set
return Objects.hash(productId, snapshotDate);
}
Entity with @IdClass
Now the entity. Here the key fields live directly on it and are marked with @Id. And above it sits @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) // Tell JPA: "my id is described by this class"
public class InventorySnapshotFlat {
// Part of identity (PK)
@Id
private Long productId;
// Part of identity (PK)
@Id
private LocalDate snapshotDate;
// Regular attribute, not part of PK
private Integer quantity;
}
The advantage of @IdClass is that you do not carry snapshot.getId().getProductId() around — you just have snapshot.getProductId(). The downside is that the key is kind of “spread out” across the entity, and you constantly have to remember: these two fields are special, they are not just attributes, they are identity.
Repository
The repository looks almost the same as with @EmbeddedId, only the key class is different and the entity is different.
import org.springframework.data.jpa.repository.JpaRepository;
public interface InventorySnapshotFlatRepository
extends JpaRepository<InventorySnapshotFlat, InventorySnapshotKey> {
}
Yes, findById still wants InventorySnapshotKey. So from the point of view of the consumer API, you do not gain “one field instead of an object.” You gain readability of the fields inside the entity itself.
7. Choice: @EmbeddedId or @IdClass
When you first encounter two ways to do the same thing, you want to ask: “which one is correct?” For JPA in general, there is no universal winner, but for our project we can make the choice stricter: InventorySnapshot stays on @EmbeddedId. For this entity, the key really lives as one value (productId, snapshotDate), and this option reads better in services, tests, and findById(). @IdClass remains nearby as a normal JPA tool that you should be able to read and assess, but not as a second parallel snapshot of the same entity.
If a composite key is perceived as a single semantic object, @EmbeddedId usually reads more easily. For InventorySnapshot, this feels true: the “snapshot identifier” is a separate thing (product, date). Plus, @EmbeddedId fits nicely with what we already discussed: value objects and @Embeddable. You get a single object that can be passed to findById().
If, on the other hand, you want the most “flat” entity possible and prefer to see the id parts directly on it, @IdClass may be more comfortable. But you pay for that comfort with discipline: names and types must match, and any small mistake turns into an annoying startup bug.
There is also another practical factor: how you will map the relation to Product. For @EmbeddedId, the typical path is @MapsId, and it is quite natural. For @IdClass, the relation is also possible, but it often leads to a more “creaky” mapping (for example, duplicated columns or insertable=false, updatable=false). We will not go into complex variants in this lecture, but it is important to know: the choice of annotation affects not only beauty, but also mapping capabilities.
8. Flyway migration for a composite key
It is very easy to write annotations and forget that in the end everything comes down to the table. But in our project rules, the schema lives only through Flyway, and that is good discipline: you always see what is actually stored in the database. For a composite key, this is especially useful because the primary key in the database also becomes composite.
Here is a minimal migration example (shortened) so that we get the inventory_snapshot table. Here the primary key is (product_id, snapshot_date):
create table inventory_snapshot (
product_id bigint not null, -- part of the composite PK: product
snapshot_date date not null, -- part of the composite PK: snapshot date
quantity integer not null, -- snapshot attribute
primary key (product_id, snapshot_date) -- composite primary key
);
If you add a relation to product, then in a real project you would also add a foreign key to the product table. But right now we only need to fix the principle: a composite key is not only a “Java object,” it is also a real composite structure on the database side.
9. Typical mistakes when working with @EmbeddedId and @IdClass
Mistake #1: forgot equals() / hashCode() on the id class.
This is one of the most “expensive” mistakes, because it can show up in strange ways: somewhere findById does not find anything, somewhere the persistence context starts behaving unpredictably, somewhere tests become flaky for no visible reason. A composite id participates in identity, so correct equality is not an option, but a required part of the contract.
Mistake #2: made the id class mutable and then started changing its fields.
In a composite key, the fields must be stable. If you changed snapshotDate on InventorySnapshotId, you have effectively “renamed” the row’s primary key. In the object world this looks like “just adjusted the date a bit,” but in the database world it is already “a different row.” Hibernate does not like this kind of life, and neither will you when you see the result in SQL and exceptions.
Mistake #3: with @IdClass, field names/types do not match between the entity and the key class.
This is a classic: in the entity you have private Long productId;, but in the key class you have private long productId;, or you named the field productID and think “well, Java will figure it out.” Java may figure it out, but JPA will not. And you get problems that look like “Hibernate is broken,” while in reality the problem is broken correspondence discipline.
Mistake #4: trying to partially “generate” a composite key.
Sometimes you want to think: “let productId come from the link to Product, and I will not set the date, it will somehow be filled in later.” For a composite id, this is almost always a bad idea. The composite key must be fully defined before persist(). Otherwise you get either an exception, or a row with an unexpected key, or a lot of implicit logic that is hard to explain in code review later.
Mistake #5: choosing a composite key just because “it is closer to the relational model.”
A composite key is justified when it really expresses the natural identity of a row. If the entity has its own lifecycle, other tables will reference it, and you do not want to carry two fields as a foreign key everywhere, then a technical surrogate id may be simpler. A composite key is a tool, not a lifestyle.
GO TO FULL VERSION