Composite Primary Keys in Spring Data JPA: Complete Guide to @IdClass and @EmbeddedId
Composite primary keys are essential when you need to uniquely identify database rows using multiple columns. Spring Data JPA provides two distinct approaches to handle composite keys: @IdClass and @EmbeddedId. This blog covers both strategies with production-ready implementations and explains when to use each.
Understanding Composite Primary Keys
A composite primary key is a primary key composed of two or more columns that together uniquely identify a record. Unlike single-column primary keys, composite keys are necessary when no single field can guarantee uniqueness.
Common Use Cases in Production
Many-to-Many Join Tables:
java// User can have multiple posts, Post can belong to multiple users UserPost: (userId, postId) as composite key
Time-Series Data:
java// Stock prices tracked by symbol and timestamp StockPrice: (symbol, timestamp) as composite key
Multi-Tenant Applications:
java// Data partitioned by tenant and entity TenantData: (tenantId, recordId) as composite key
Audit Tables:
java// Track changes with entity ID and version AuditLog: (entityId, version) as composite key
Requirements for Composite Key Classes
Before implementing composite keys, your composite key class must satisfy these requirements:
- Must be public so JPA can access it
- Must implement Serializable for caching and session management
- Must override equals() and hashCode() for entity comparison
- Must provide a no-argument constructor for JPA instantiation
These requirements ensure JPA can properly manage entity lifecycle and persistence context operations.
Why Serializable Matters
JPA providers need to serialize composite keys for:
- Second-level cache storage
- Session replication in clustered environments
- Detached entity management
- Transaction rollback and recovery
Project Setup with Lombok
Lombok significantly reduces boilerplate code for composite key classes. Add this dependency to your pom.xml:
plaintext<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>
Lombok annotations we'll use:
- @Data: Generates getters, setters, toString, equals, and hashCode
- @NoArgsConstructor: Generates no-argument constructor
- @AllArgsConstructor: Generates constructor with all fields
- @EqualsAndHashCode: Explicitly generates equals and hashCode methods
Base Entity Setup
Let's define our base entities before creating the composite key examples. We have User and Post entities with standard single-column primary keys.
User Entity
javaimport lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import javax.persistence.*; @Data @Entity @Table(name = "user_details") @NoArgsConstructor @AllArgsConstructor public class UserDetails { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "user_id") private Integer userId; @Column(name = "first_name") private String firstName; @Column(name = "middle_name") private String middleName; @Column(name = "last_name") private String lastName; @Column(name = "email") private String email; @Column(name = "password_hash") private String passwordHash; @Column(name = "created_on") private Long createdOn; @Column(name = "created_by") private String createdBy; @Column(name = "modified_on") private Long modifiedOn; @Column(name = "modified_by") private String modifiedBy; }
Post Entity
javaimport lombok.Data; import lombok.ToString; import javax.persistence.*; @Data @Entity @ToString @Table(name = "post") public class Post { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "post_id") private Integer postId; @Column(name = "post_name") private String postName; @Column(name = "post_description") private String postDescription; @Column(name = "created_on") private Long createdOn; @Column(name = "created_by") private String createdBy; @Column(name = "modified_on") private Long modifiedOn; @Column(name = "modified_by") private String modifiedBy; }
Now we'll create a UserPost entity that represents the many-to-many relationship between users and posts using a composite key.
Approach 1: Using @IdClass Annotation
The @IdClass approach keeps composite key fields directly in the entity class and references a separate class for the composite key definition.
How @IdClass Works
With @IdClass, you declare the composite key fields in your entity class with @Id annotations, and JPA uses a separate class to represent the composite key identity.
Implementation
Composite Key Class:
javaimport lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import java.io.Serializable; @Data @NoArgsConstructor @EqualsAndHashCode public class UserPostId implements Serializable { private static final long serialVersionUID = 2702030623316532366L; private Integer userId; private Integer postId; }
Entity Class:
javaimport lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import javax.persistence.*; import java.io.Serializable; @Data @Entity @Table(name = "user_post") @AllArgsConstructor @NoArgsConstructor @IdClass(UserPostId.class) public class UserPost implements Serializable { private static final long serialVersionUID = -909206262878526790L; @Id @Column(name = "user_id") private Integer userId; @Id @Column(name = "post_id") private Integer postId; }
Key Observations
Field Names Must Match: The field names in the @IdClass (userId, postId) must exactly match the field names in the entity class. JPA uses reflection to map these fields.
Direct Field Access: You can access composite key fields directly on the entity:
javauserPost.getUserId() userPost.getPostId()
JPA Repository:
java@Repository public interface UserPostRepository extends JpaRepository<UserPost, UserPostId> { @Query("SELECT up.postId FROM UserPost up WHERE up.userId = :userId") Set<Integer> getUserPostsByUserId(@Param("userId") Integer userId); List<UserPost> findByUserId(Integer userId); void deleteByUserIdAndPostId(Integer userId, Integer postId); }
Advantages of @IdClass
- Natural field access: Query and access composite key fields as if they were regular entity fields
- Simpler entity structure: Composite key fields are part of the entity, not nested
- Cleaner JPQL queries: No need to navigate through a composite key object
Approach 2: Using @EmbeddedId Annotation
The @EmbeddedId approach encapsulates all composite key fields into a single embedded object within the entity.
How @EmbeddedId Works
With @EmbeddedId, you create an embeddable class containing all composite key fields and embed it as a single field in your entity class.
Implementation
Embeddable Composite Key Class:
javaimport lombok.AllArgsConstructor; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import javax.persistence.Embeddable; import java.io.Serializable; @Data @NoArgsConstructor @AllArgsConstructor @EqualsAndHashCode @Embeddable public class UserPostId implements Serializable { private static final long serialVersionUID = 2702030623316532366L; private Integer userId; private Integer postId; }
Entity Class:
javaimport lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import javax.persistence.*; import java.io.Serializable; @Data @Entity @Table(name = "user_post") @AllArgsConstructor @NoArgsConstructor public class UserPost implements Serializable { private static final long serialVersionUID = -9092062626878526790L; @EmbeddedId private UserPostId userPostId; }
Key Observations
-
@Embeddable Annotation: The composite key class uses @Embeddable instead of being a plain class. This tells JPA that this class is meant to be embedded in other entities.
-
Single Field in Entity: The entity has only one field of type UserPostId. All composite key columns are accessed through this single object.
-
Accessing Composite Key Fields:
javauserPost.getUserPostId().getUserId() userPost.getUserPostId().getPostId()
JPA Repository:
java@Repository public interface UserPostRepository extends JpaRepository<UserPost, UserPostId> { @Query("SELECT up.userPostId.postId FROM UserPost up WHERE up.userPostId.userId = :userId") Set<Integer> getUserPostsByUserId(@Param("userId") Integer userId); List<UserPost> findByUserPostIdUserId(Integer userId); void deleteByUserPostId(UserPostId userPostId); }
Advantages of @EmbeddedId
- Object-oriented design: Composite key is a proper object with its own identity
- Reusability: The same embeddable class can be used in multiple entities
- Type safety: Pass the entire composite key as a single parameter
- Clear separation: Distinguishes between key fields and data fields
Comparison: @IdClass vs @EmbeddedId
| Aspect | @IdClass | @EmbeddedId |
|---|---|---|
| Key fields location | Directly in entity | Nested in embedded object |
| JPQL queries | Simpler syntax | Requires navigation through embedded object |
| Field access | Direct (entity.userId) | Nested (entity.id.userId) |
| Method naming | findByUserId | findByUserPostIdUserId |
| Type safety | Less (separate fields) | More (single composite object) |
| JPA spec preference | Older approach | Recommended by JPA spec |
| Reusability | Limited | High (can reuse @Embeddable) |
| Readability | More intuitive for simple cases | Better for complex composite keys |
Custom Queries with Composite Keys
The main difference between the two approaches is how you reference composite key fields in JPQL queries.
With @IdClass
java@Repository public interface UserPostRepository extends JpaRepository<UserPost, UserPostId> { @Query("SELECT up FROM UserPost up WHERE up.userId = :userId") List<UserPost> findPostsByUser(@Param("userId") Integer userId); @Query("SELECT up.postId FROM UserPost up WHERE up.userId = :userId") Set<Integer> getPostIdsForUser(@Param("userId") Integer userId); @Query("SELECT COUNT(up) FROM UserPost up WHERE up.userId = :userId") Long countPostsByUser(@Param("userId") Integer userId); List<UserPost> findByUserIdAndPostId(Integer userId, Integer postId); }
Note: You reference composite key fields directly as entity fields.
With @EmbeddedId
java@Repository public interface UserPostRepository extends JpaRepository<UserPost, UserPostId> { @Query("SELECT up FROM UserPost up WHERE up.userPostId.userId = :userId") List<UserPost> findPostsByUser(@Param("userId") Integer userId); @Query("SELECT up.userPostId.postId FROM UserPost up WHERE up.userPostId.userId = :userId") Set<Integer> getPostIdsForUser(@Param("userId") Integer userId); @Query("SELECT COUNT(up) FROM UserPost up WHERE up.userPostId.userId = :userId") Long countPostsByUser(@Param("userId") Integer userId); List<UserPost> findByUserPostIdUserId(Integer userId); }
Note: You must navigate through the embedded object (userPostId) to access composite key fields.
Working with Composite Keys in Service Layer
Using @IdClass
java@Service public class UserPostService { @Autowired private UserPostRepository repository; public UserPost createUserPost(Integer userId, Integer postId) { UserPost userPost = new UserPost(); userPost.setUserId(userId); userPost.setPostId(postId); return repository.save(userPost); } public Optional<UserPost> findUserPost(Integer userId, Integer postId) { UserPostId id = new UserPostId(); id.setUserId(userId); id.setPostId(postId); return repository.findById(id); } public void deleteUserPost(Integer userId, Integer postId) { UserPostId id = new UserPostId(); id.setUserId(userId); id.setPostId(postId); repository.deleteById(id); } }
Using @EmbeddedId
java@Service public class UserPostService { @Autowired private UserPostRepository repository; public UserPost createUserPost(Integer userId, Integer postId) { UserPostId id = new UserPostId(userId, postId); UserPost userPost = new UserPost(); userPost.setUserPostId(id); return repository.save(userPost); } public Optional<UserPost> findUserPost(Integer userId, Integer postId) { UserPostId id = new UserPostId(userId, postId); return repository.findById(id); } public void deleteUserPost(UserPostId id) { repository.deleteById(id); } }
Using @EmbeddedId provides better encapsulation since you work with a single composite key object rather than multiple separate fields.
Production Best Practices
1. Choose Based on Use Case
Use @IdClass when:
- Composite key is simple with 2 to 3 fields
- You want direct field access in queries
- Legacy code already uses this approach
- JPQL queries need to be concise
Use @EmbeddedId when:
- Composite key is complex with 3 or more fields
- You need to pass composite key as a parameter
- You want better encapsulation and type safety
- The same composite key is used across multiple entities
2. Always Override equals() and hashCode()
java@Data @EqualsAndHashCode @Embeddable public class UserPostId implements Serializable { private Integer userId; private Integer postId; // Lombok generates proper equals/hashCode based on all fields // This is crucial for Set operations and JPA entity comparison }
Without proper equals and hashCode implementations, you will encounter bugs with collections and persistence context management.
3. Use Immutable Composite Keys
java@Value // Lombok: makes class immutable @Embeddable public class UserPostId implements Serializable { Integer userId; Integer postId; // All fields are final, no setters generated // Constructor with all fields is generated }
Immutable composite keys prevent accidental modification after entity creation that could corrupt the persistence context.
4. Index Composite Key Columns
sqlCREATE INDEX idx_user_post ON user_post(user_id, post_id);
Composite primary keys automatically create an index, but consider additional indexes for query patterns:
sql-- If you frequently query by userId alone CREATE INDEX idx_user_post_user ON user_post(user_id);
5. Handle Null Values Carefully
Composite key fields should never be null. Always validate at the application level:
java@Embeddable public class UserPostId implements Serializable { @Column(nullable = false) private Integer userId; @Column(nullable = false) private Integer postId; public UserPostId(Integer userId, Integer postId) { Objects.requireNonNull(userId, "userId cannot be null"); Objects.requireNonNull(postId, "postId cannot be null"); this.userId = userId; this.postId = postId; } }
6. Consider Performance Implications
@IdClass advantages:
- Slightly faster queries due to direct field mapping
- Less memory overhead (no extra object)
@EmbeddedId advantages:
- Better for caching (single object key)
- Cleaner code organization
The performance difference is negligible for most applications. Make your choice based on code maintainability.
7. Testing Composite Keys
java@SpringBootTest public class UserPostRepositoryTest { @Autowired private UserPostRepository repository; @Test public void testSaveAndFind() { UserPostId id = new UserPostId(1, 100); UserPost userPost = new UserPost(); userPost.setUserPostId(id); repository.save(userPost); Optional<UserPost> found = repository.findById(id); assertTrue(found.isPresent()); assertEquals(1, found.get().getUserPostId().getUserId()); assertEquals(100, found.get().getUserPostId().getPostId()); } @Test public void testEqualsAndHashCode() { UserPostId id1 = new UserPostId(1, 100); UserPostId id2 = new UserPostId(1, 100); UserPostId id3 = new UserPostId(1, 101); assertEquals(id1, id2); assertEquals(id1.hashCode(), id2.hashCode()); assertNotEquals(id1, id3); } }
Common Pitfalls to Avoid
1. Forgetting Serializable
java// WRONG - will fail at runtime public class UserPostId { private Integer userId; private Integer postId; } // CORRECT public class UserPostId implements Serializable { private static final long serialVersionUID = 1L; private Integer userId; private Integer postId; }
2. Mismatched Field Names with @IdClass
java// WRONG - field names don't match @IdClass(UserPostId.class) public class UserPost { @Id private Integer user; // Doesn't match userId in UserPostId @Id private Integer post; // Doesn't match postId in UserPostId }
Field names in the entity and composite key class must be identical.
3. Missing No-Argument Constructor
java// WRONG - JPA cannot instantiate public class UserPostId implements Serializable { private Integer userId; private Integer postId; public UserPostId(Integer userId, Integer postId) { this.userId = userId; this.postId = postId; } } // CORRECT - has no-arg constructor @NoArgsConstructor @AllArgsConstructor public class UserPostId implements Serializable { private Integer userId; private Integer postId; }
4. Incorrect Query Syntax
java// WRONG - with @EmbeddedId @Query("SELECT up FROM UserPost up WHERE up.userId = :userId") // CORRECT - with @EmbeddedId @Query("SELECT up FROM UserPost up WHERE up.userPostId.userId = :userId")
5. Not Using @Embeddable with @EmbeddedId
java// WRONG public class UserPostId implements Serializable { } // CORRECT @Embeddable public class UserPostId implements Serializable { }
Migration Strategy for Existing Tables
If you're adding composite keys to existing tables:
1. Add Columns if Needed
sqlALTER TABLE user_post ADD COLUMN user_id INT NOT NULL; ALTER TABLE user_post ADD COLUMN post_id INT NOT NULL;
2. Populate Data
sqlUPDATE user_post SET user_id = ..., post_id = ... WHERE ...;
3. Create Composite Primary Key
sqlALTER TABLE user_post DROP PRIMARY KEY; ALTER TABLE user_post ADD PRIMARY KEY (user_id, post_id);
4. Update Entity Classes
Change from single primary key to composite primary key using either @IdClass or @EmbeddedId.
5. Test Thoroughly
Run integration tests to ensure all queries still work correctly with the new composite key structure.
Conclusion
Both @IdClass and @EmbeddedId are valid approaches for implementing composite primary keys in Spring Data JPA. The choice depends on your specific use case and team preferences.
Quick decision guide:
- Simple composite keys with straightforward queries: Use @IdClass
- Complex composite keys or need for reusability: Use @EmbeddedId
- Type safety and encapsulation are priorities: Use @EmbeddedId
- Legacy code or existing patterns: Match the existing approach
Related Posts
Continue exploring similar topics
Spring Boot 3.x: What Actually Changed (and What Matters)
A practical look at Spring Boot 3.x features that change how you build services - virtual threads, reactive patterns, security gotchas, and performance lessons from production.
Securing Application Secrets with HashiCorp Vault and Spring Boot
A comprehensive guide to integrating HashiCorp Vault with Spring Boot applications for secure secrets management, dynamic credential rotation, and enhanced application security.
Dockerizing a spring boot application
Dockerizing an application means making our application run in a docker container..