Composite Keys in JPA: A Complete Guide to @IdClass and @EmbeddedId

Illustration for Composite Keys in JPA: A Complete Guide to @IdClass and @EmbeddedId
By Last updated:

When designing database schemas, you’ll often encounter tables where a single primary key isn’t enough to uniquely identify a record. Instead, multiple columns together act as the identifier. This is known as a composite key (or composite primary key).

In Java Persistence API (JPA), composite keys are essential for mapping entities to legacy databases or real-world systems where multiple attributes (like orderId + productId) are required to ensure uniqueness.

In this tutorial, you’ll learn everything about composite keys in JPA, focusing on two powerful annotations:

  • @IdClass
  • @EmbeddedId

We’ll cover setup, CRUD operations, querying, best practices, pitfalls, and integration scenarios so you can confidently use composite keys in your applications.


What is a Composite Key?

A composite key is a combination of two or more columns in a database table that together form a unique identifier for a record.

Example:
In an Order Details table, the uniqueness of a record is defined by both order_id and product_id.

CREATE TABLE order_details (
    order_id BIGINT NOT NULL,
    product_id BIGINT NOT NULL,
    quantity INT,
    price DECIMAL(10,2),
    PRIMARY KEY (order_id, product_id)
);

Composite Keys in JPA: Two Approaches

JPA provides two approaches to represent composite keys:

  1. @IdClass — defines a separate class holding key fields and referenced in the entity.
  2. @EmbeddedId — uses an embeddable class annotated with @Embeddable that becomes part of the entity.

Approach 1: Using @IdClass

Step 1: Create the Composite Key Class

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

public class OrderDetailId implements Serializable {
    private Long orderId;
    private Long productId;

    // Default constructor
    public OrderDetailId() {}

    public OrderDetailId(Long orderId, Long productId) {
        this.orderId = orderId;
        this.productId = productId;
    }

    // equals() and hashCode()
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof OrderDetailId)) return false;
        OrderDetailId that = (OrderDetailId) o;
        return Objects.equals(orderId, that.orderId) &&
               Objects.equals(productId, that.productId);
    }

    @Override
    public int hashCode() {
        return Objects.hash(orderId, productId);
    }
}

Step 2: Define the Entity

import jakarta.persistence.*;

@Entity
@IdClass(OrderDetailId.class)
@Table(name = "order_details")
public class OrderDetail {

    @Id
    private Long orderId;

    @Id
    private Long productId;

    private int quantity;
    private double price;

    // getters and setters
}

Approach 2: Using @EmbeddedId

Step 1: Create the Embeddable Key Class

import jakarta.persistence.Embeddable;
import java.io.Serializable;
import java.util.Objects;

@Embeddable
public class OrderDetailKey implements Serializable {

    private Long orderId;
    private Long productId;

    public OrderDetailKey() {}

    public OrderDetailKey(Long orderId, Long productId) {
        this.orderId = orderId;
        this.productId = productId;
    }

    // equals and hashCode
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof OrderDetailKey)) return false;
        OrderDetailKey that = (OrderDetailKey) o;
        return Objects.equals(orderId, that.orderId) &&
               Objects.equals(productId, that.productId);
    }

    @Override
    public int hashCode() {
        return Objects.hash(orderId, productId);
    }
}

Step 2: Define the Entity with @EmbeddedId

import jakarta.persistence.*;

@Entity
@Table(name = "order_details")
public class OrderDetailEmbedded {

    @EmbeddedId
    private OrderDetailKey id;

    private int quantity;
    private double price;

    // getters and setters
}

CRUD Operations with Composite Keys

Persist Example

OrderDetailId id = new OrderDetailId(1L, 100L);
OrderDetail orderDetail = new OrderDetail();
orderDetail.setOrderId(1L);
orderDetail.setProductId(100L);
orderDetail.setQuantity(2);
orderDetail.setPrice(500.0);

entityManager.persist(orderDetail);

Find Example

OrderDetailId id = new OrderDetailId(1L, 100L);
OrderDetail od = entityManager.find(OrderDetail.class, id);

Update Example

entityManager.getTransaction().begin();
od.setQuantity(3);
entityManager.merge(od);
entityManager.getTransaction().commit();

Delete Example

entityManager.getTransaction().begin();
entityManager.remove(od);
entityManager.getTransaction().commit();

Querying with JPQL and Criteria API

JPQL Example

TypedQuery<OrderDetail> query = entityManager.createQuery(
    "SELECT o FROM OrderDetail o WHERE o.orderId = :orderId", OrderDetail.class);
query.setParameter("orderId", 1L);
List<OrderDetail> results = query.getResultList();

Criteria API Example

CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<OrderDetail> cq = cb.createQuery(OrderDetail.class);
Root<OrderDetail> root = cq.from(OrderDetail.class);
cq.select(root).where(cb.equal(root.get("orderId"), 1L));

List<OrderDetail> list = entityManager.createQuery(cq).getResultList();

Real-World Use Cases

  • Order Management Systems: Composite keys for orderId + productId.
  • School Databases: studentId + courseId for enrollments.
  • Banking Applications: accountId + transactionId for transaction records.

Anti-Patterns and Pitfalls

  • Forgetting to implement equals() and hashCode() → leads to caching issues.
  • Using generated values with composite keys (not recommended).
  • Overusing composite keys instead of using surrogate keys (UUID/sequence).

Best Practices

  • Always implement equals() and hashCode() in key classes.
  • Prefer @EmbeddedId for cleaner code, unless you need legacy DB mapping.
  • Keep key classes immutable (fields final, no setters).
  • Ensure keys are small and stable (don’t use large text fields).

📌 JPA Version Notes

  • JPA 2.0: Introduced Criteria API, Metamodel → helpful with composite keys.
  • JPA 2.1: Added entity graphs and stored procedures → can optimize composite key queries.
  • Jakarta Persistence (Jakarta EE 9+): Package renamed from javax.persistence to jakarta.persistence.
  • Spring Boot 3.x: Uses Jakarta Persistence under the hood.

Conclusion and Key Takeaways

  • Composite keys are necessary when a single column can’t uniquely identify a row.
  • JPA supports composite keys using @IdClass (legacy-style) and @EmbeddedId (preferred).
  • Always define equals() and hashCode() properly.
  • Use Criteria API and JPQL for efficient querying.
  • Follow best practices to avoid performance and maintainability issues.

FAQ: Expert-Level Questions

1. What’s the difference between JPA and Hibernate?
JPA is a specification; Hibernate is one of its implementations.

2. How does JPA handle the persistence context?
It works like a “classroom attendance register” — tracking managed entities.

3. What are the drawbacks of eager fetching in JPA?
It loads all data upfront, causing memory bloat and slow performance.

4. How can I solve the N+1 select problem with JPA?
Use JOIN FETCH, entity graphs, or batch fetching strategies.

5. Can I use JPA without Hibernate?
Yes, alternatives include EclipseLink, OpenJPA, and DataNucleus.

6. What’s the best strategy for inheritance mapping in JPA?
Depends on use case: SINGLE_TABLE (fastest), JOINED (normalized), TABLE_PER_CLASS (rare).

7. How does JPA handle composite keys?
Through @IdClass or @EmbeddedId annotations.

8. What changes with Jakarta Persistence?
Only the package name (javax.persistencejakarta.persistence), but APIs remain same.

9. Is JPA suitable for microservices?
Yes, but consider lightweight ORMs or direct JDBC for high-performance services.

10. When should I avoid using JPA?
When ultra-low latency, batch-heavy operations, or highly dynamic SQL is required.