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:
@IdClass
— defines a separate class holding key fields and referenced in the entity.@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()
andhashCode()
→ 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()
andhashCode()
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
tojakarta.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()
andhashCode()
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.persistence
→ jakarta.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.