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 in JPA Buddy's account, and results showed that about 55% of developers would use composite keys if required:
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 a 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:
- Define all key fields in the JPA entity.
- Mark them with
@Id
. - Create the same fields in a separate class that represents the composite key.
- 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 the @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 should you 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);
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);
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. However, 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
The 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 in the repository:
Collar collar = collarRepository.findByPet(Pet pet);
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.
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.