Skip to content
CalliCoder

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. protected is enough; JPA instantiates the key reflectively.
  • equals and hashCode over every key field. The persistence context is a Map keyed by entity identity. Without these, two EmployeeId instances describing the same row are different keys, so findById(new EmployeeId("ACME", "0042")) looks up a bucket nothing was ever stored in and returns Optional.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 HashMap key 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 fieldsinside the key objectduplicated on both classes
Access in codeemployee.getId().getCompanyCode()employee.getCompanyCode()
JPQLe.id.companyCodee.companyCode
Key as a value objectnaturalawkward

@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.