Back to Entity Mapping: Keys, Columns & Annotations
Q1Question 1 of 8

What is a composite primary key in JPA?

Key Concepts
  • A composite primary key uses two or more columns together to uniquely identify a row in the database.
  • @EmbeddedId wraps the key fields in a separate @Embeddable class used as one field on the entity.
  • @IdClass declares a plain ID class with matching fields while the entity carries multiple @Id annotations.
  • Both approaches require the key class to implement Serializable and override equals() and hashCode().
  • Java records are ideal composite key classes because they auto-generate equals, hashCode, and implement Serializable.
  • @EmbeddedId is generally preferred for new projects because the key is a first-class object usable in JPQL and as a Map key.
  • Both strategies produce identical SQL and database schemas at runtime.

Definition

A composite primary key uses two or more columns as the PK, mapped via @EmbeddedId (key as one embedded object) or @IdClass (multiple @Id fields mirroring a separate ID class).
  • Composite Primary Key -- a primary key that uses two or more columns together, such as (account_id, balance_date), to uniquely identify a row.
  • @EmbeddedId -- creates a separate @Embeddable key class and uses it as a single field on the entity, accessed via entity.getId().getAccountId().
  • @IdClass -- creates a plain ID class with matching field names and types, while the entity declares multiple @Id fields, accessed directly via entity.getAccountId().
  • Serializable requirement -- both approaches require the key class to implement Serializable so JPA can use it as a cache key and persistence context identifier.
  • equals() and hashCode() -- the key class must override both methods so JPA can correctly compare entity identity in the persistence context.

At National Bank of India, a daily balance record is identified by BOTH the account number AND the date. Neither alone is unique because one account has many dates and one date has many accounts. @EmbeddedId is like creating a "Daily Balance Key" card that holds both fields, and the record points to this card. @IdClass is like writing both fields directly on the record, with a matching lookup card kept behind the scenes for JPA. Both work, but the key card approach (@EmbeddedId) is more object-oriented because the key is a first-class object you can pass around.

Understanding the Concepts

What is a composite primary key?

A composite primary key uses two or more columns together to uniquely identify a row. In banking, a daily balance record is identified by both account_id and balance_date because one account has many dates, and one date has many accounts. JPA provides two approaches to map such keys: @EmbeddedId and @IdClass.

  • A composite key is needed when no single column can uniquely identify a row.
  • The combination of columns must be unique across all rows.
  • The key class must implement Serializable and override equals() and hashCode().
JAVA
package com.bank.model;

// The database table:
// CREATE TABLE daily_balances (
//     account_id  VARCHAR(20) NOT NULL,
//     balance_date DATE        NOT NULL,
//     balance     DECIMAL(15,2),
//     day_change  DECIMAL(15,2),
//     PRIMARY KEY (account_id, balance_date)
// );

How does @EmbeddedId work?

The @EmbeddedId approach wraps all key fields into a separate @Embeddable class. The entity then has one field of that type annotated with @EmbeddedId. This makes the composite key a first-class object that you can pass to methods, use as a Map key, and reference cleanly in JPQL.

  • The key class must be annotated @Embeddable.
  • The entity has exactly one @EmbeddedId field.
  • Access to key fields goes through the embedded object: entity.getId().getAccountId().
  • Java records are ideal key classes because they auto-generate equals, hashCode, toString, and implement Serializable with zero boilerplate.
JAVA
package com.bank.model;

import jakarta.persistence.*;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;

// Step 1: Key class as a record
@Embeddable
public record DailyBalanceId(
    @Column(name = "account_id") String accountId,
    @Column(name = "balance_date") LocalDate balanceDate
) implements Serializable {}

// Step 2: Entity uses @EmbeddedId
@Entity
@Table(name = "daily_balances")
public class DailyBalance {

    @EmbeddedId
    private DailyBalanceId id;

    @Column(precision = 15, scale = 2)
    private BigDecimal balance;

    @Column(name = "day_change", precision = 15, scale = 2)
    private BigDecimal dayChange;

    // Access: entity.getId().accountId()
    // Access: entity.getId().balanceDate()
}
JAVA
package com.bank.repository;

import com.bank.model.DailyBalance;
import com.bank.model.DailyBalanceId;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

public interface DailyBalanceRepository
        extends JpaRepository<DailyBalance, DailyBalanceId> {

    // Spring Data navigates the embedded key: id -> accountId
    List<DailyBalance> findByIdAccountId(String accountId);
}
JAVA
package com.bank.service;

import com.bank.model.*;
import com.bank.repository.DailyBalanceRepository;
import java.time.LocalDate;
import java.util.Optional;

public class BalanceService {
    private final DailyBalanceRepository repo;

    public BalanceService(DailyBalanceRepository repo) {
        this.repo = repo;
    }

    public Optional<DailyBalance> findBalance(String accountId, LocalDate date) {
        DailyBalanceId key = new DailyBalanceId(accountId, date);
        return repo.findById(key);
    }
}
TEXT
// Usage:
DailyBalanceId key = new DailyBalanceId("SAV2001", LocalDate.of(2024, 1, 15));
Optional<DailyBalance> balance = repo.findById(key);
// SQL: SELECT * FROM daily_balances
//      WHERE account_id = 'SAV2001' AND balance_date = '2024-01-15'

How does @IdClass work?

The @IdClass approach puts the key fields directly on the entity as separate @Id fields. A separate ID class with matching field names exists for JPA to use internally. Access to key fields is flat: entity.getAccountId() rather than entity.getId().getAccountId().

  • The entity is annotated with @IdClass(DailyBalanceId.class).
  • Each key field on the entity is annotated with @Id.
  • The ID class must have fields with exactly the same names and types as the entity's @Id fields.
  • The ID class must implement Serializable and override equals() and hashCode().
JAVA
package com.bank.model;

import jakarta.persistence.*;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;

// Step 1: ID class (matches entity @Id fields)
public record DailyBalanceId(
    String accountId,
    LocalDate balanceDate
) implements Serializable {}

// Step 2: Entity uses @IdClass with multiple @Id fields
@Entity
@Table(name = "daily_balances")
@IdClass(DailyBalanceId.class)
public class DailyBalance {

    @Id
    @Column(name = "account_id")
    private String accountId;

    @Id
    @Column(name = "balance_date")
    private LocalDate balanceDate;

    @Column(precision = 15, scale = 2)
    private BigDecimal balance;

    @Column(name = "day_change", precision = 15, scale = 2)
    private BigDecimal dayChange;

    // Access: entity.getAccountId() directly
    // Access: entity.getBalanceDate() directly
}
TEXT
// Repository is the same either way:
public interface DailyBalanceRepository
        extends JpaRepository<DailyBalance, DailyBalanceId> {}

// Lookup is the same either way:
DailyBalanceId key = new DailyBalanceId("SAV2001", LocalDate.now());
Optional<DailyBalance> balance = repo.findById(key);

How is @EmbeddedId different from @IdClass?

| Aspect | @EmbeddedId | @IdClass |
|---|---|---|
| Key class annotation | @Embeddable | Plain class or record |
| Entity annotation | @EmbeddedId on one field | @IdClass on class + @Id on each field |
| Key field access | entity.getId().getAccountId() | entity.getAccountId() |
| @Column placement | On key class fields | On entity fields |
| JPQL syntax | WHERE d.id.accountId = :id | WHERE d.accountId = :id |
| Key as object | Yes, first-class object | Not directly on entity |
| Field duplication | None | Entity and ID class duplicate fields |
| Preferred for | New projects, clean design | Legacy schemas, flat access |

JAVA
package com.bank.service;

import jakarta.persistence.EntityManager;
import com.bank.model.DailyBalance;
import java.util.List;

public class QueryComparison {

    // JPQL with @EmbeddedId:
    // "SELECT d FROM DailyBalance d WHERE d.id.accountId = :acct"

    // JPQL with @IdClass:
    // "SELECT d FROM DailyBalance d WHERE d.accountId = :acct"

    // Both generate the same SQL:
    // SELECT * FROM daily_balances WHERE account_id = ?
}

What are the rules for composite key classes?

The JPA specification requires composite key classes to follow strict rules. Violating any of these causes runtime exceptions or silent bugs in entity identity comparison.

  • The key class must implement java.io.Serializable.
  • The key class must override equals() and hashCode() using all key fields.
  • The key class must have a public no-argument constructor (records satisfy this via the canonical constructor).
  • Field names in an @IdClass must exactly match the @Id field names on the entity.
  • Field types must also match exactly.
JAVA
package com.bank.model;

import java.io.Serializable;
import java.time.LocalDate;
import java.util.Objects;

// Traditional class approach (when not using records)
public class DailyBalanceId implements Serializable {

    private String accountId;
    private LocalDate balanceDate;

    public DailyBalanceId() {} // required no-arg constructor

    public DailyBalanceId(String accountId, LocalDate balanceDate) {
        this.accountId = accountId;
        this.balanceDate = balanceDate;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof DailyBalanceId that)) return false;
        return Objects.equals(accountId, that.accountId)
            && Objects.equals(balanceDate, that.balanceDate);
    }

    @Override
    public int hashCode() {
        return Objects.hash(accountId, balanceDate);
    }
}

What happens if equals() and hashCode() are missing?

Without proper equals and hashCode, JPA cannot correctly identify entities in the persistence context. Two key objects with the same field values will be treated as different identities, leading to duplicate entities, stale data, and cache corruption.

JAVA
package com.bank.model;

import java.io.Serializable;
import java.time.LocalDate;

// WRONG: no equals/hashCode override
public class BadKeyId implements Serializable {
    private String accountId;
    private LocalDate balanceDate;

    public BadKeyId(String accountId, LocalDate balanceDate) {
        this.accountId = accountId;
        this.balanceDate = balanceDate;
    }
    // Uses Object.equals() which compares by reference, not by value!
}
JAVA
package com.bank.test;

import com.bank.model.BadKeyId;
import java.time.LocalDate;

public class KeyBugDemo {
    public static void main(String[] args) {
        BadKeyId key1 = new BadKeyId("SAV2001", LocalDate.of(2024, 1, 15));
        BadKeyId key2 = new BadKeyId("SAV2001", LocalDate.of(2024, 1, 15));

        System.out.println(key1.equals(key2)); // false! Should be true
        // JPA treats these as DIFFERENT entities
        // Persistence context stores duplicates
        // Cache lookups fail for the same logical key
    }
}
TEXT
Expected output:
false
JAVA
// CORRECT: use a record (auto equals/hashCode)
public record CorrectKeyId(
    String accountId, LocalDate balanceDate
) implements Serializable {}

// new CorrectKeyId("SAV2001", date).equals(new CorrectKeyId("SAV2001", date))
// -> true! JPA correctly identifies the entity.

What are common mistakes with composite keys?

Developers frequently make mistakes when mapping composite primary keys. Here are the most common errors and their corrections.

JAVA
// WRONG: Forgetting Serializable
@Embeddable
public record AccountDateId(String accountId, LocalDate date) {}
// Runtime error: Composite-id class must implement Serializable

// CORRECT: Implement Serializable
@Embeddable
public record AccountDateId(
    String accountId, LocalDate date
) implements Serializable {}
JAVA
// WRONG: @IdClass field names do not match entity @Id field names
public record BalanceKey(String acctId, LocalDate dt) implements Serializable {}

@Entity
@IdClass(BalanceKey.class)
public class DailyBalance {
    @Id private String accountId;    // "accountId" != "acctId"
    @Id private LocalDate balanceDate; // "balanceDate" != "dt"
    // Hibernate cannot match fields -> error at startup
}

// CORRECT: Names must match exactly
public record BalanceKey(
    String accountId, LocalDate balanceDate
) implements Serializable {}
JAVA
// WRONG: Setting @Version inside the composite key
@Embeddable
public record BadKey(
    String accountId,
    @Version Long version  // Version must NOT be in the key
) implements Serializable {}
// @Version belongs on the entity, not on the key class

When should you use @EmbeddedId vs @IdClass?

Choose @EmbeddedId for new projects and clean object-oriented design. The key is a first-class object you can pass to methods, use as a Map key, and reference cleanly in JPQL with d.id.accountId. Choose @IdClass when working with legacy schemas where the entity already has flat @Id fields, or when you prefer direct field access on the entity.

JAVA
// @EmbeddedId is preferred when:
// 1. Key is passed around as an object (method parameters, Map keys)
// 2. JPQL references key fields: WHERE d.id.accountId = :id
// 3. One field on the entity reduces repetition
// 4. New project with no legacy constraints

// @IdClass is preferred when:
// 1. Legacy schema with flat fields already on entity
// 2. Flat field access: entity.getAccountId() (no navigation)
// 3. Some Spring Data derived queries work more naturally

What about thread safety and concurrency?

Composite key classes themselves are typically immutable (especially when using records), which makes them inherently thread-safe. However, the entities they identify are mutable and require proper locking when accessed concurrently.

JAVA
package com.bank.service;

import com.bank.model.*;
import com.bank.repository.DailyBalanceRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDate;

@Service
public class BalanceUpdateService {

    private final DailyBalanceRepository repo;

    public BalanceUpdateService(DailyBalanceRepository repo) {
        this.repo = repo;
    }

    @Transactional
    public void recordDailyBalance(String accountId, LocalDate date,
                                    BigDecimal balance) {
        DailyBalanceId key = new DailyBalanceId(accountId, date);
        // Record key is immutable and thread-safe
        // Entity itself needs @Version for optimistic locking
        DailyBalance record = repo.findById(key)
            .orElse(new DailyBalance());
        record.setBalance(balance);
        repo.save(record);
    }
}

What are common interview follow-up questions?

Interviewers often ask these follow-ups after composite key questions:

  • Can you use @GeneratedValue with composite keys? No, because auto-generation applies to single-column keys only.
  • Can one field of a composite key be a foreign key? Yes, use @MapsId to share a field between the composite key and a @ManyToOne relationship.
  • What if you add a new column to the composite key? You must update the key class, regenerate equals/hashCode, and migrate existing data.
  • Can you use @EmbeddedId and @IdClass together? No, an entity uses one or the other, never both.
JAVA
package com.bank.model;

import jakarta.persistence.*;
import java.io.Serializable;
import java.math.BigDecimal;

// Using @MapsId to share a key field with a relationship
@Embeddable
public record AccountBranchId(
    @Column(name = "account_id") String accountId,
    @Column(name = "branch_code") String branchCode
) implements Serializable {}

@Entity
@Table(name = "account_branches")
public class AccountBranch {

    @EmbeddedId
    private AccountBranchId id;

    @ManyToOne(fetch = FetchType.LAZY)
    @MapsId("accountId")  // shares accountId with the composite key
    @JoinColumn(name = "account_id")
    private Account account;

    @Column(precision = 15, scale = 2)
    private BigDecimal branchBalance;
}

Visual Overview

DIAGRAM
  @EmbeddedId approach:
  ┌─────────────────────────────────────────────┐
  │  DailyBalance (Entity)                       │
  │  ┌───────────────────────────────┐           │
  │  │  @EmbeddedId DailyBalanceId   │           │
  │  │  ├── accountId: SAV2001       │           │
  │  │  └── balanceDate: 2024-01-15  │           │
  │  └───────────────────────────────┘           │
  │  balance: 150000.00                          │
  │  dayChange: 5000.00                          │
  └─────────────────────────────────────────────┘
        Access: entity.getId().accountId()

  @IdClass approach:
  ┌─────────────────────────────────────────────┐
  │  DailyBalance (Entity)                       │
  │  @Id accountId: SAV2001                      │
  │  @Id balanceDate: 2024-01-15                 │
  │  balance: 150000.00                          │
  │  dayChange: 5000.00                          │
  └─────────────────────────────────────────────┘
        Access: entity.getAccountId()

  ┌───────────────────────────────┐
  │  DailyBalanceId (ID class)    │  (used by JPA internally)
  │  accountId: SAV2001           │
  │  balanceDate: 2024-01-15     │
  └───────────────────────────────┘

  Both produce the same SQL:
  ┌──────────────────────────────────────────────┐
  │  daily_balances                               │
  │  PK: (account_id, balance_date)               │
  │  ├── account_id   VARCHAR(20)  NOT NULL       │
  │  ├── balance_date DATE         NOT NULL       │
  │  ├── balance      DECIMAL(15,2)               │
  │  └── day_change   DECIMAL(15,2)               │
  └──────────────────────────────────────────────┘

Key Takeaway

A composite primary key in JPA uses two or more columns together to uniquely identify a row. In our National Bank of India daily balance system, each record is identified by both account_id and balance_date because one account has balances for many dates and one date has balances for many accounts. JPA offers two mapping approaches: @EmbeddedId creates a separate @Embeddable key class used as a single field on the entity, while @IdClass declares a plain ID class with matching fields and the entity carries multiple @Id annotations. Both require the key class to implement Serializable and have proper equals and hashCode.

@EmbeddedId is generally preferred for new projects because the key is a first-class object that can be passed to methods, used as Map keys, and referenced cleanly in JPQL with WHERE d.id.accountId = :id. Java records are ideal key classes because they auto-generate equals, hashCode, and Serializable with zero boilerplate. @IdClass is useful for legacy schemas where the entity already has flat key fields and you prefer direct access like entity.getAccountId(). Both approaches produce identical SQL and database schemas at runtime.

The critical requirement that trips developers is proper equals and hashCode on the key class. Without them, JPA cannot correctly identify entities in the persistence context cache, leading to duplicate entities, stale data, and broken cache lookups. Records eliminate this risk entirely because their equals and hashCode are generated automatically from all fields.

Remember one sentence: @EmbeddedId wraps composite key fields into one embedded object on the entity while @IdClass mirrors them as multiple @Id fields, and both require the key class to be Serializable with correct equals and hashCode.

---

Share
Use ← → arrow keys to navigate