March 21, 2022

The Ultimate Guide on Composite IDs in JPA Entities

Previously, we talked about IDs generated in database and applications, discussed the pros and cons of various strategies for generation and implementation details. Now it is time to discuss less popular but still useful ID type – composite ID.

Do We Need Composite keys at All?

It might seem that composite keys are not needed in modern applications, but it is not like this. Recently there was a poll on Twitter, and results showed that about 55% of developers would use composite keys if required:

So, composite keys are not that rare, especially when using the “database-first” development approach. This approach is practical when we either build an application over an existing “legacy” database or use carefully and thoroughly optimized database schema usually developed by a DBA.

For example, we can create tables to store countries, regions, and cities. We can make a country ID a part of a region’s primary key. And then create a composite primary key for the “cities” table that will include country ID and region ID. It can save us one “join” when we search all cities for a particular country. Another example is that having a composite primary key in the many-to-many association table is a natural thing.

Based on the above, we can conclude that we should learn how to deal with composite keys in our JPA entities.

Two Ways to Implement a Composite Key

As an example, we will use the “PetClinic” application. Let’s extend it in the following way: for every Pet, we will store information about a collar with a custom message like “This is Toby. If you found this dog, please call. +1-555-345-12-33”. And every collar is identified by the pair “collar number and pet”.

Let’s follow the “database-first” approach and write a DDL in the first place:


CREATE TABLE collar 
( 
    collar_message VARCHAR(255), 
    serial_id      BIGINT  NOT NULL, 
    pet_id         INTEGER NOT NULL, 
    CONSTRAINT pk_collar PRIMARY KEY (serial_id, pet_id) 
); 
 
ALTER TABLE collar 
    ADD CONSTRAINT FK_COLLAR_ON_PET FOREIGN KEY (pet_id) REFERENCES pets (id); 

So, how can we represent this composite key in our JPA entities? There are two options to do this:

The first approach – is to use the @Embeddable class, which contains all fields representing a composite key. We should create a field in the JPA entity of this type and annotate it with @EmbeddedId.


@Embeddable 
public class CollarId implements Serializable { 
 
   @Column(nullable = false) 
   private Long serialId; 
 
   @OneToOne(optional = false) 
   @JoinColumn(name = "pet_id", nullable = false) 
   private Pet pet; 

//Setters and getters are omitted 
 
} 

@Entity 
public class Collar { 
 
   @EmbeddedId 
   private CollarId collarId; 
 
   @Column(name = "collar_message") 
   private String collarMessage; 
//Setters and getters are omitted 
} 


Another option for composite key mapping - @IdClass. For this case, we should:

  1. Define all key fields in the JPA entity.
  2. Mark them with @Id.
  3. Create the same fields in a separate class that represents the composite key.
  4. Set this ID class as an ID for the entity using an annotation @IdClass

@Entity 
@Table(name = "collar") 
@IdClass(CollarId.class) 
public class Collar { 
 
   @Id 
   @Column(name = "serial_id", nullable = false) 
   private Long serialId; 
 
   @Id 
   @OneToOne(optional = false, orphanRemoval = true) 
   @JoinColumn(name = "pet_id", nullable = false) 
   private Pet pet; 
 
   @Column(name = "collar_message") 
   private String collarMessage; 
//Setters and getters are omitted 
} 

public class CollarId implements Serializable { 
 
   private Long serialId; 
 
   private Pet pet; 
//Setters and getters are omitted 
} 

Whether we use @IdClass or @EmbeddedId approach, composite key classes must be serializable and define equals() and hashCode() methods.

Not surprisingly, both JPA entity definitions work for the table above. So, which composite key approach to choose? The answer depends on the use case.

Queries

This is where you can probably see the most significant difference between approaches in composite key definitions. We can perform a search using either the whole key or its part. For our case, it means that we know either Pet or collar serial ID or both.

Let’s start with @EmbeddedId. When we have the primary key as a separate entity, searching the full key looks simpler. In the corresponding CollarRepository, we need to define a method like this:


Collar findByCollarId(CollarId collarId); 

When we talk about searching using a partial key, the query method is a bit more complex. We should add an underscore character to the method name to be able to use embedded class properties:


List<Collar> findAllByCollarId_SerialId(Long serialId); 

List<Collar> findAllByCollarId_Pet(Pet pet); 

And this looks different from the @IdClass case. Let’s define the query method that performs a query when we know both the serial number and the pet:


Collar findBySerialIdAndPet(Long serialId, Pet pet); 

And now the partial key:


List<Collar> findBySerialId(Long serialId); 
 
List<Collar> findByPet(Pet pet); 

As you can see, we treat the composite primary key as a separate entity for the first case, and we can say that by the look of our queries. When we use the @IdClass approach, we don’t emphasize that the Collar entity has a composite primary key and treats those fields like others. Those query methods will generate the same queries. But there is a case when entity structure affects underlying queries. Let’s have a look at the repository definition:


interface CollarRepository extends JpaRepository<Collar, CollarId>  

Such definition means that the method findById will use the CollarId class as an ID parameter. Let’s enable SQL logs and try to find a collar by ID:


Pet pet = new Pet(); 
pet.setId(1); 
CollarId collarId = new CollarId(); 
collarId.setPet(pet); 
collarId.setSerialId(1L); 
Collar byCollarIdIs = collarRepository.findById(collarId).orElseThrow(); 

For the @EmbeddedId case, the query will look as expected:


select pet_id, serial_id, collar_message  

from collar  

where pet_id=? and serial_id=? 

When we try the same JPA query for the @IdClass case, we can see the following:


select collar.pet_id, collar.serial_id, collar.collar_message, pets.id, pets.name, pets.birth_date, pets.type_id, types.id, types.name  

from collar  

inner join pets on collar.pet_id=pets.id  

left outer join types on pets.type_id=types.id  

where collar.pet_id=? and collar.serial_id=? 

Why is that? The explanation is simple: the Pet reference in the composite primary key is treated as “just reference”, so Hibernate applies all query generation rules, including eager *ToOne fetch type by default.

Conclusion: composite key definition affects queries more than we might expect. In most cases @EmbeddedId approach gives us a clear picture and better understanding of an entity’s structure. We can see the entity’s primary key, and Hibernate generates optimized queries, considering the entity structure.

@IdClass approach should be used in cases when we do not use the full composite primary key in queries often, but only parts of it.

References in composite keys

In the example above for the @EmbeddedId case, we’ve created a reference to the Pet entity right in the primary key class. It led to “funny”, though explicit-looking queries that include references to this class:


List<Collar> findAllByCollarId_Pet(Pet pet); 

For this entity structure, to get a Pet from a collar, we need to write something like this:


String petName = collar.getCollarId().getPet().getName(); 

We need to mention that there is no such case for the entities with @IdClass; all the references are right inside the Collar entity. So, the question is: is it possible to move the Pet entity from the embeddable primary key to the Collar keeping the same table structure? The answer is: “Yes. That’s what @MapsId is for”. Let’s have a look at the new entities’ structure.


@Embeddable 
public class CollarId implements Serializable { 
 
   @Column(name = "serial_id", nullable = false) 
   private Long serialId; 
 
   @Column(name = "pet_id", nullable = false) 
   private Long petId; 

//Setters and getters are omitted 
} 

 

@Entity 
public class Collar { 
 
   @EmbeddedId 
   private CollarId collarId; 
 
   @Column(name = "collar_message") 
   private String collarMessage; 
 
   @OneToOne 
   @MapsId("petId") 
   private Pet pet; 
//Setters and getters are omitted 
} 

Please pay attention to the @MapsId annotation on the one-to-one association for the pet field. The magic behind this annotation is simple: it instructs Hibernate to get the ID for a referenced entity from another entity’s field. For our case, it is the petId field in the CollarId class. That’s it! Now we can write a nice query method:


Collar collar = findByPet(Pet pet); 

And what about @IdClass? We can use @MapsId, but we have to duplicate all fields from the PK class in the entity, so classes will look like this:


public class CollarId implements Serializable { 
 
   private Long serialId; 
 
   private Long petId; 
//Setters and getters are omitted 
} 

 

@Entity 
@IdClass(CollarId.class) 
public class Collar { 
 
   @Id 
   @Column(name = "serial_id", nullable = false) 
   private Long serialId; 
 
   @Id 
   @Column(name = "pet_id", nullable = false) 
   private Long petId; 
 
   @OneToOne 
   @MapsId("petId") 
   private Pet pet; 
 
   @Column(name = "collar_message") 
   private String collarMessage; 
//Setters and getters are omitted 
} 

If we have a look at the entity, we can see that it contains both numeric FK values (serialId and petId) and the referenced entity itself (pet). This approach adds additional complexity, and we do not gain anything in particular.

Conclusion: We can create references either in composite primary key classes or entities themselves. The @EmbeddedId approach for the primary key definition provides more flexibility – we can use the FK ID value in the PK class and keep referenced entity in the “main” entity. We can do the same for the @IdClass approach, but having both FK ID and the referenced entity itself in the JPA entity looks a bit redundant.

Using Spring Data JPA, Hibernate or EclipseLink and code in IntelliJ IDEA? Make sure you are ultimately productive with the JPA Buddy plugin!

It will always give you a valuable hint and even generate the desired piece of code for you: JPA entities and Spring Data repositories, Liquibase changelogs and Flyway migrations, DTOs and MapStruct mappers and even more!

Final Thoughts

Having composite keys in a database is not rare, especially for databases created a long time ago. We can easily map such keys to JPA entities using a separate class to represent the composite ID.

There are two options: @Embeddable class and @IdClass. The first option looks preferable; it provides better clarity of the JPA entity structure and allows developers to use primary keys explicitly in queries with proper Hibernate query optimizations.

Both implementations allow us to have foreign key references in primary keys. The @Embeddable approach provides a bit more flexibility: we can configure PK to keep FK ID reference only. The referenced entity will be stored in the “main” entity.

The conclusion: in most cases, prefer @Embeddable composite keys. They might look a bit scary initially, but they provide better entity structure observability and enough flexibility to implement even the most complex composite IDs.