Definition
- Composite primary key – a key made of two or more columns together, where neither column alone is unique but the combination is.
- @EmbeddedId – uses a single @Embeddable key class annotated on one field of the entity; you access the parts through the key object, e.g., statement.getId().getAccountId().
- @IdClass – keeps the key fields as separate @Id fields directly on the entity, plus a plain non-entity mirror class listed in @IdClass(...) whose field names and types match.
- Key class requirements – both approaches require the key class to be Serializable, to have a public no-arg constructor, and to implement equals and hashCode over all key fields.
- @MapsId – when a composite key column is also a foreign key (derived identity), @MapsId lets a @ManyToOne share a key column with the composite key.
A monthly account statement at National Bank of India is uniquely identified by the account number PLUS the statement period together. With @EmbeddedId you put those two values inside one little key envelope object and the statement carries that envelope. With @IdClass you keep the account number and period as two loose fields on the statement and separately describe a matching key form so the bank's filing system has one handle for the pair.
Understanding the Concepts
What is a composite primary key?
A composite primary key is an identity made of two or more columns that are unique only in combination. Neither column alone is unique, but together they identify exactly one row. In banking, a monthly statement is identified by the account id and the period together, because one account has many monthly statements and one month has statements for many accounts.
CREATE TABLE monthly_statements (
account_id VARCHAR(20) NOT NULL,
period VARCHAR(7) NOT NULL, -- e.g. '2026-01'
closing_balance DECIMAL(15,2),
PRIMARY KEY (account_id, period)
);
Composite key: (account_id, period)
- account_id alone is NOT unique (SAV2001 has 12 monthly statements)
- period alone is NOT unique (2026-01 has statements for many accounts)
- Together they uniquely identify one statement row.
How does @EmbeddedId work?
@EmbeddedId uses one key class annotated @Embeddable, and the entity holds one field of that type annotated @EmbeddedId. You access the parts through the key object.
package com.bank.model;
import jakarta.persistence.Embeddable;
import java.io.Serializable;
import java.util.Objects;
@Embeddable
public class StatementId implements Serializable {
private String accountId;
private String period; // e.g. "2026-01"
public StatementId() {}
public StatementId(String accountId, String period) {
this.accountId = accountId;
this.period = period;
}
// getters
public String getAccountId() { return accountId; }
public String getPeriod() { return period; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof StatementId other)) return false;
return Objects.equals(accountId, other.accountId)
&& Objects.equals(period, other.period);
}
@Override
public int hashCode() {
return Objects.hash(accountId, period);
}
}
package com.bank.model;
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "monthly_statements")
public class MonthlyStatement {
@EmbeddedId
private StatementId id; // the whole key as one object
@Column(name = "closing_balance", precision = 15, scale = 2)
private BigDecimal closingBalance;
// access parts via id.getAccountId(), id.getPeriod()
}
Lookup: repo.findById(new StatementId("SAV2001", "2026-01"))
JPQL: WHERE s.id.accountId = :acc AND s.id.period = :period
How does @IdClass work?
@IdClass keeps the key fields as ordinary @Id fields on the entity and declares a separate mirror class in @IdClass. The mirror class field names and types must match the entity's @Id fields exactly.
package com.bank.model;
import java.io.Serializable;
import java.util.Objects;
public class StatementKey implements Serializable {
private String accountId; // names + types mirror the @Id fields
private String period;
public StatementKey() {}
public StatementKey(String accountId, String period) {
this.accountId = accountId;
this.period = period;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof StatementKey other)) return false;
return Objects.equals(accountId, other.accountId)
&& Objects.equals(period, other.period);
}
@Override
public int hashCode() {
return Objects.hash(accountId, period);
}
}
package com.bank.model;
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "monthly_statements")
@IdClass(StatementKey.class)
public class MonthlyStatement {
@Id
@Column(name = "account_id")
private String accountId; // separate @Id field
@Id
@Column(name = "period")
private String period; // separate @Id field
@Column(name = "closing_balance", precision = 15, scale = 2)
private BigDecimal closingBalance;
}
Lookup: repo.findById(new StatementKey("SAV2001", "2026-01"))
JPQL: WHERE s.accountId = :acc AND s.period = :period (natural, no id. prefix)
How do @EmbeddedId and @IdClass compare?
| Aspect | @EmbeddedId | @IdClass |
|---|---|---|
| Key class annotation | @Embeddable | Plain class (no annotation) |
| Entity field | One field: @EmbeddedId StatementId id | Separate @Id fields: accountId, period |
| JPQL access | s.id.accountId (navigate through key) | s.accountId (direct, more natural) |
| Key as object | First-class object, easy to pass around | Separate mirror class, exists only for findById |
| Duplication | No field duplication | Fields exist on both entity and key class |
| @MapsId support | Natural fit for derived identity | Also works but less clean |
// @EmbeddedId: key is a first-class object
package com.bank.service;
import com.bank.model.StatementId;
import com.bank.model.MonthlyStatement;
import com.bank.repository.StatementRepository;
public class StatementService {
public MonthlyStatement getStatement(String accountId, String period) {
StatementId key = new StatementId(accountId, period);
return statementRepository.findById(key).orElseThrow();
}
}
// @IdClass: fields directly on entity, simpler JPQL
// WHERE s.accountId = :acc AND s.period = :period
What are the shared rules for both approaches?
Any composite-key class must be Serializable, must have a public no-arg constructor, and must implement equals and hashCode based on all key fields. Hibernate uses equals/hashCode to locate entities in the persistence context (the identity map). A broken equals silently breaks caching and merging.
// WRONG: missing equals/hashCode on key class
package com.bank.model;
import java.io.Serializable;
public class BrokenKey implements Serializable {
private String accountId;
private String period;
// NO equals or hashCode!
// Hibernate cannot locate entities correctly in the identity map
// findById may return wrong results or cache misses
}
// CORRECT: equals and hashCode over all key fields
package com.bank.model;
import java.io.Serializable;
import java.util.Objects;
public class CorrectKey implements Serializable {
private String accountId;
private String period;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof CorrectKey other)) return false;
return Objects.equals(accountId, other.accountId)
&& Objects.equals(period, other.period);
}
@Override
public int hashCode() {
return Objects.hash(accountId, period);
}
}
What about derived identity with @MapsId?
When a composite key also acts as a foreign key into a parent (a derived identity), @MapsId lets a @ManyToOne share a key column. This is common in join-table entities.
package com.bank.model;
import jakarta.persistence.*;
@Entity
public class AccountBeneficiary {
@EmbeddedId
private AccountBeneficiaryId id;
@ManyToOne(fetch = FetchType.LAZY)
@MapsId("accountId") // shares the accountId column with the composite key
@JoinColumn(name = "account_id")
private Account account;
@Column(name = "share_percent")
private int sharePercent;
}
@MapsId("accountId") tells Hibernate:
- The accountId field in the composite key is also the FK to Account.
- No separate column needed -- they share one physical column.
What are common mistakes?
The most common mistake is forgetting equals/hashCode on the key class, which silently breaks the persistence context identity map. A second mistake is confusing the mirror class field names with column names.
// WRONG: @IdClass mirror has wrong field name
public class WrongKey implements java.io.Serializable {
private String acctId; // WRONG! Entity has "accountId", not "acctId"
private String period;
// Field names MUST match the entity's @Id fields exactly
}
What should a senior engineer know?
A senior engineer defaults to @EmbeddedId because grouping the key into one serializable object models the identity as a real concept and reduces mistakes. They reach for @IdClass only when the id fields should sit directly on the entity for simpler JPQL or to match a legacy schema. They build composite keys from stable, immutable business values like an account id and a period that never change.
package com.bank.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import com.bank.model.MonthlyStatement;
import com.bank.model.StatementId;
import java.util.List;
public interface StatementRepository extends JpaRepository<MonthlyStatement, StatementId> {
// Derived finder navigates into the embedded key
List<MonthlyStatement> findByIdAccountId(String accountId);
// Spring Data resolves id.accountId from the method name
}
Visual Overview
@EmbeddedId vs @IdClass
============================================
@EmbeddedId:
+-------------------+ +--------------------+
| MonthlyStatement | | StatementId |
|-------------------| | (@Embeddable) |
| @EmbeddedId |------>| accountId: String |
| StatementId id | | period: String |
| closingBalance | | equals/hashCode |
+-------------------+ +--------------------+
JPQL: WHERE s.id.accountId = :acc
@IdClass:
+-------------------+ +--------------------+
| MonthlyStatement | | StatementKey |
|-------------------| | (plain class) |
| @Id accountId |<----->| accountId: String |
| @Id period |<----->| period: String |
| closingBalance | | equals/hashCode |
+-------------------+ +--------------------+
@IdClass(StatementKey.class)
JPQL: WHERE s.accountId = :acc (more natural)
Both require:
- Serializable
- No-arg constructor
- equals + hashCode on ALL key fields
Key Takeaway
A composite primary key is an identity made of two or more columns that are unique only in combination, and JPA gives two ways to map it. With @EmbeddedId, Rajesh creates one key class annotated @Embeddable and the entity holds a single field of that type annotated @EmbeddedId, so the key is a first-class object that can be passed around and used in findById. With @IdClass, the key fields sit as separate @Id fields directly on the entity, and a separate mirror class in @IdClass provides the single-object identity that the persistence context needs.
Both styles require the key class to be Serializable, to have a no-arg constructor, and to implement equals and hashCode over all key fields. Hibernate uses these methods in its identity map, so a broken equals silently breaks caching and merging. In the statement service at National Bank of India, the monthly statement is identified by the account id and the period together, modelled with @EmbeddedId so the key is one immutable value object.
Pankaj defaults to @EmbeddedId because grouping the key into one object models the identity as a real concept and reduces mistakes, and reaches for @IdClass only when the id fields should sit directly on the entity for simpler JPQL or to match a legacy schema. He builds composite keys from stable, immutable business values like an account id and a period that never change.
Remember one sentence: @EmbeddedId groups the composite key into one embeddable object, @IdClass keeps separate @Id fields with a mirror class, and both require Serializable with correct equals and hashCode.
---