Why does Hibernate insist that a composite identifier class implement equals() and hashCode(), and what concretely goes wrong at runtime when the implementation is missing, incomplete, or based on mutable state?
answer
- Persistence context = Map<EntityKey, Object>
- Missing equals → repeated SELECTs, two managed copies
- Partial equals → wrong entity returned, silent corruption
- Keys immutable: never mutate after persist
- Records give equals/hashCode free (Hibernate 6)
basics
~20 sHibernate keys the persistence context and the second-level cache by identifier value, comparing keys with equals/hashCode. Without a correct implementation, lookups miss: the same row loads as multiple instances, find() re-queries, merge misbehaves, and cache hits never happen. Keys must also be immutable, or the hash changes under the map.
solid answer
~50 sHibernate's persistence context is an identity map keyed by `(entity type, identifier)`. For a single-column key that is a `Long`, whose `equals`/`hashCode` are already correct. For an `@EmbeddedId` or `@IdClass` the key is *your* class, so Hibernate can only compare keys as well as you implemented them. With the default identity-based `Object.equals`, two `OrderLineId(1,2)` instances are unequal. Consequences: - `em.find(OrderLine.class, new OrderLineId(1,2))` does not find the already-managed instance, so Hibernate issues another SELECT and — depending on path — you can end up with two managed objects for one row, or a `NonUniqueObjectException` on `merge`. - Second-level cache and query-cache lookups always miss, since cache keys are compared by equality. - Collections and de-duplication in application code silently break. Rules: implement `equals`/`hashCode` over **all** key attributes, base them on immutable state, never include a lazily-derived or mutable field, and never change key values after `persist` — mutating the key corrupts the identity map. Records (Hibernate 6) give you both methods for free.
code
java · 8 lines@Embeddable
public record OrderLineId(Long orderId, Long productId) implements Serializable {}
@Entity
public class OrderLine {
@EmbeddedId private OrderLineId id;
private int quantity;
}go deeper
State the rule and why it exists: Hibernate looks entities up by identifier, so the key class must compare by value over all its parts, and be Serializable.
Describe the concrete symptoms — repeated SELECTs, two managed instances for one row, second-level cache misses — and write a correct implementation from memory.
Diagnose the partial-equals corruption case, explain the identity-map mechanics, insist on immutability of key values, and recommend records or an equivalent immutable value type.
Set the standard: composite keys are immutable value objects with generated structural equality, enforced by convention or static analysis; and weigh whether composite keys are worth this fragility versus surrogate keys.
## Where equality is used A persistence context is fundamentally a `Map<EntityKey, Object>`, where `EntityKey` combines the entity name and the identifier value. Everything that has to answer "do I already have this row?" goes through that map: - `find()` / `getReference()` — first-level cache lookup before hitting the database. - Loading a `@ManyToOne` — resolving the target by id. - `merge()` — locating the managed copy for a detached instance. - Flush-time deduplication and cascade traversal. - Second-level cache and query cache — the cached region is keyed by identifier. With a simple `@Id Long id`, `Long.equals` and `Long.hashCode` do the right thing and nobody thinks about it. With a composite key, **you** supply the equality contract, and Hibernate is only as correct as your implementation. ## Failure modes **Missing implementation (identity equality).** Every freshly constructed key is unequal to every other, so the identity map never hits: ```java OrderLine a = em.find(OrderLine.class, new OrderLineId(1L, 2L)); // SELECT OrderLine b = em.find(OrderLine.class, new OrderLineId(1L, 2L)); // SELECT again a == b; // false — two managed instances for one row ``` That second instance is the dangerous part: two managed objects representing one row can both be dirty, producing conflicting UPDATEs at flush, and `merge` of a detached copy may raise `NonUniqueObjectException`. It also makes `hashCode`-based collections in your own code behave unpredictably. **Partial implementation.** Comparing only `orderId` and ignoring `productId` makes distinct rows equal. Now the identity map returns the *wrong* entity for a key, `@ElementCollection` sets collapse elements, and second-level cache entries shadow each other — a silent data-corruption class of bug, far worse than the extra-SELECT case. **Mutable key state.** If a key attribute changes after the object has been placed in a hash map — including Hibernate's internal maps — its bucket no longer matches its hash, so lookups miss even though `equals` would return true. Concretely: `line.getId().setProductId(99)` after `persist` leaves the persistence context indexing the entity under the old hash while the database row still carries the old PK. Hibernate does not support changing a primary key; a key change is a delete plus an insert. **hashCode inconsistent with equals.** Any pair where `a.equals(b)` but `a.hashCode() != b.hashCode()` breaks every hash structure. Generated code from an IDE is fine as long as you generate both from the same field set. ## Writing it correctly ```java @Embeddable public class OrderLineId implements Serializable { @Column(name = "order_id") private Long orderId; @Column(name = "product_id") private Long productId; protected OrderLineId() {} public OrderLineId(Long orderId, Long productId) { this.orderId = Objects.requireNonNull(orderId); this.productId = Objects.requireNonNull(productId); } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof OrderLineId that)) return false; return Objects.equals(orderId, that.orderId) && Objects.equals(productId, that.productId); } @Override public int hashCode() { return Objects.hash(orderId, productId); } } ``` Checklist: all key attributes included; `Serializable`; no-arg constructor (may be `protected`) for the provider; no setters, so the key is effectively immutable; `getClass()` or a pattern-matching `instanceof` — be aware that a strict `getClass()` check is safest for id classes because they are never proxied, unlike entities. On Hibernate 6 a **Java record** is a legal `@EmbeddedId` type and gives you a correct `equals`/`hashCode`, immutability, and a canonical constructor for free — the tidiest option when your toolchain allows it. ## Related but distinct Equality of the *entity* itself (whether `OrderLine.equals` should compare ids or business keys) is a separate discussion; here the contract is narrower and non-negotiable: the identifier class must behave like a value with structural equality over the full key.
- What happens if application code mutates a field of an @EmbeddedId after the entity has been persisted?Hibernate does not support primary-key mutation. The persistence context still indexes the entity under the original identifier, so lookups by the new key miss and lookups by the old key return an entity whose id field no longer matches. At flush you may get a spurious UPDATE against the old PK, an optimistic-lock style failure, or silent divergence. The correct operation is to remove the row and insert a new one.
- Why is a Java record often the best @EmbeddedId type on Hibernate 6?A record is final and its components are immutable, which is exactly the contract a key needs, and the compiler generates structural `equals`/`hashCode` over all components so partial or inconsistent implementations become impossible. Hibernate 6 supports records as embeddables, including as `@EmbeddedId`, instantiating them through the canonical constructor.
saying these in an interview costs you the question
- Saying equals/hashCode on the id class is only for application code, not for Hibernate
- Comparing only one of several key attributes
- Generating hashCode from a different field set than equals
- Adding setters to an id class and mutating key values after persist
- Assuming Hibernate compares composite keys column-by-column and ignores your equals