JPA Composite Primary Key Example with Spring Boot
Published Updated Spring Boot 13 min read
@EmbeddedId against @IdClass, why the key class must implement equals and hashCode or findById silently misses, and the @MapsId form that stops the foreign key being stored twice.
A composite primary key needs a class to represent it, and that class carries a requirement JPA does
not enforce and Hibernate does not warn about: it must implement equals and hashCode. Omit them
and everything compiles, rows insert correctly, and findById returns empty for a row that is
demonstrably there.
Written against Spring Boot 3.2, Hibernate 6.4 and Java 17.
@EmbeddedId, the key is an object
@Embeddable
public class EmployeeId implements Serializable {
private String companyCode;
private String employeeNumber;
protected EmployeeId() { } // required by JPA
public EmployeeId(String companyCode, String employeeNumber) {
this.companyCode = companyCode;
this.employeeNumber = employeeNumber;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof EmployeeId other)) return false;
return Objects.equals(companyCode, other.companyCode)
&& Objects.equals(employeeNumber, other.employeeNumber);
}
@Override
public int hashCode() {
return Objects.hash(companyCode, employeeNumber);
}
}
@Entity
@Table(name = "employees")
public class Employee {
@EmbeddedId
private EmployeeId id;
private String name;
private String department;
}
Four requirements, all of them load-bearing:
Serializable. The specification requires it, and Hibernate uses it when the key crosses a cache or a session boundary.- A no-argument constructor.
protectedis enough; JPA instantiates the key reflectively. equalsandhashCodeover every key field. The persistence context is aMapkeyed by entity identity. Without these, twoEmployeeIdinstances describing the same row are different keys, sofindById(new EmployeeId("ACME", "0042"))looks up a bucket nothing was ever stored in and returnsOptional.empty()for a row the database holds.- Immutability in practice. Do not expose setters. Changing a field of a key that is already in
the persistence context corrupts the map the same way a mutable
HashMapkey does.
This is the failure that costs the most time, because the insert works. The row is in the table, a
native query finds it, and only findById disagrees.
@IdClass, the key fields are on the entity
public class EmployeeId implements Serializable {
private String companyCode;
private String employeeNumber;
// no-arg constructor, equals, hashCode — same requirements
}
@Entity
@Table(name = "employees")
@IdClass(EmployeeId.class)
public class Employee {
@Id private String companyCode;
@Id private String employeeNumber;
private String name;
}
The fields live on the entity and the key class mirrors them by name and type. Get a name or a type wrong and Hibernate fails at bootstrap, which is at least a loud failure.
Which to choose:
@EmbeddedId | @IdClass | |
|---|---|---|
| Key fields | inside the key object | duplicated on both classes |
| Access in code | employee.getId().getCompanyCode() | employee.getCompanyCode() |
| JPQL | e.id.companyCode | e.companyCode |
| Key as a value object | natural | awkward |
@EmbeddedId is the better default: one definition of the key, and it can be passed around as a
value. @IdClass reads more naturally in JPQL and suits a legacy schema where the columns are
already treated as ordinary fields.
Repositories
public interface EmployeeRepository extends JpaRepository<Employee, EmployeeId> {
List<Employee> findByIdCompanyCode(String companyCode); // @EmbeddedId: traverses id.companyCode
List<Employee> findByDepartment(String department);
}
With @EmbeddedId, a derived query into the key uses the path — findByIdCompanyCode maps to
id.companyCode. Spring Data resolves the ambiguity by trying the longest property match first, so
findById_CompanyCode with an explicit underscore is worth using when a field name could be read two
ways.
With @IdClass the fields are directly on the entity, so it is plain findByCompanyCode.
Optional<Employee> found = repository.findById(new EmployeeId("ACME", "0042"));
That line is the one that fails silently without equals and hashCode.
@MapsId: a composite key that includes a foreign key
The common real case is a join table with attributes, where the key is the pair of foreign keys. Written naively, each foreign key ends up mapped twice — once as a key field, once as an association — and Hibernate reports the column as mapped by two properties.
@MapsId is the fix:
@Embeddable
public class EnrolmentId implements Serializable {
private Long studentId;
private Long courseId;
// equals, hashCode, no-arg constructor
}
@Entity
@Table(name = "enrolments")
public class Enrolment {
@EmbeddedId
private EnrolmentId id;
@ManyToOne(fetch = FetchType.LAZY)
@MapsId("studentId")
@JoinColumn(name = "student_id")
private Student student;
@ManyToOne(fetch = FetchType.LAZY)
@MapsId("courseId")
@JoinColumn(name = "course_id")
private Course course;
private Instant enrolledAt;
private String grade;
}
@MapsId("studentId") tells Hibernate that the association supplies that key field. One column in
the schema, and the key value is derived when the association is set:
Enrolment enrolment = new Enrolment();
enrolment.setStudent(student);
enrolment.setCourse(course);
enrolment.setEnrolledAt(Instant.now());
repository.save(enrolment); // id is populated from the two associations
Constructing the EnrolmentId by hand is unnecessary and, if it disagrees with the associations, is
the value that loses. This is the mapping a
many-to-many with attributes should be
promoted to.
The generated schema
SHOW CREATE TABLE employees;
Expect a PRIMARY KEY (company_code, employee_number) and no surrogate id column. Column order in
a composite primary key is not cosmetic on MySQL: the index it creates is usable for a query
filtering on the leading column alone, and useless for one filtering only on the second. Put the more
selective — or the more frequently filtered — column first, or add a secondary index for the other
direction.
There is no @GeneratedValue here. A composite key is by definition supplied by the application or
by the data, so every field must be set before persist. Saving with a null component throws an
identifier error rather than a constraint violation.
Detached entities and merge
A composite key changes how Hibernate decides whether an entity is new. With a generated id, a null
id means “insert”. With an assigned composite key the id is always populated, so save() cannot tell
a new row from a modification and issues a SELECT before every write to find out.
repository.save(employee); // SELECT ... then INSERT or UPDATE
That extra query per save is usually irrelevant and occasionally is not — a bulk import of ten
thousand rows doubles its statement count. Two ways around it: implement Persistable<EmployeeId> on
the entity and answer isNew() from a transient flag, or drop to EntityManager.persist() when the
code already knows the row is new.
The same rule explains a common surprise on update. Loading an entity, changing a key component and saving does not rename the row — it inserts a second one, because the identifier is what the row is. Changing a composite key means deleting and re-inserting, which is the strongest argument for a surrogate key when the natural key can ever change.
Should the key be composite at all?
A natural composite key documents a real uniqueness rule and removes a surrogate column. It also propagates: every table referencing this one carries both columns, joins get wider, and changing a key component means updating every referencing row.
The alternative is a generated surrogate id plus a unique constraint on the pair:
@Entity
@Table(name = "employees",
uniqueConstraints = @UniqueConstraint(columnNames = { "company_code", "employee_number" }))
public class Employee {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
}
Same guarantee, simpler references, and findById takes a Long. Reach for a composite key when the
pair genuinely is the identity — a join table, or a schema you do not control. More mappings in the
Spring Boot guides.
Frequently asked questions
Why does findById return empty for a row that exists?
The key class is missing equals and
hashCode. The persistence context keys entities by identity, so a freshly constructed key object
does not match the stored one.
Does the key class have to implement Serializable?
Yes — the JPA specification requires it, and it is used when the identifier crosses a cache boundary. It costs nothing to add.
@EmbeddedId or @IdClass?
@EmbeddedId for new code: the key is defined once and can be passed
around as a value. @IdClass reads better in JPQL and suits a legacy schema where the key columns
are already treated as ordinary fields.
Can a composite key use @GeneratedValue?
No. Every component must be supplied before persist,
either by the application or by an association mapped with @MapsId.
How do I write a derived query on part of the key?
With @EmbeddedId, traverse it —
findByIdCompanyCode, or findById_CompanyCode when the name is ambiguous. With @IdClass the
fields are on the entity, so it is findByCompanyCode.
Why is my foreign key column mapped twice?
The key field and the @ManyToOne both map it. Add
@MapsId("fieldName") to the association so it supplies the key component instead of duplicating it.
Should the key class be immutable?
Yes. Expose no setters. Mutating a key already in the
persistence context breaks the map it is stored in, in exactly the way a mutable HashMap key does.
Does column order in the primary key matter?
On MySQL, yes. The index serves queries filtering on a leading prefix of the columns and not on a trailing one. Order by selectivity, or add a secondary index.
Can I use a record as the key class?
A record gives you equals, hashCode and immutability
for free, but not the no-argument constructor JPA requires, so Hibernate cannot instantiate it. Use a
plain class with a protected no-arg constructor.
Is a surrogate key with a unique constraint better?
Often. It gives the same guarantee with
narrower foreign keys and a simpler findById. Prefer a composite key when the combination genuinely
is the row’s identity.