Composite Keys in Quarkus: Building Smarter IDs with Hibernate and Panache
When your primary key is more than just an id. Make your data model reflect the business.
Most Java developers default to using surrogate keys (or auto-generated id
fields) for their entities. It's simple, clean, and works well for CRUD. But sometimes, the best identifier isn’t artificial. It’s already there, embedded in the business logic. This tutorial looks into the world of composite keys, also known as business or natural keys, and shows you exactly how to implement them using Quarkus and Hibernate Panache.
We'll start with a real-world example: modeling student enrollments in a course. Instead of assigning random IDs, we’ll identify each record with a combination of studentId
and courseCode
.
What are Composite Keys (Business Keys)?
In traditional RDBMS design, every row needs a primary key. That key is often a meaningless number (a surrogate key), which keeps joins small and performance predictable. But in certain cases, the data itself contains the uniqueness. For example:
A student can enroll in multiple courses.
A course has many students.
But a student can only enroll in the same course once.
So studentId + courseCode
is a perfect natural key. It's meaningful, unambiguous, and aligns with business logic.
You’ll often encounter composite keys in legacy systems, normalized schemas, or integrations where real-world identifiers (like productCode
or ISBN
) matter more than some internal id
.
When are Business Keys Needed?
Advantages
Business keys make your data model more intuitive. They enforce integrity based on real-world rules, reduce the need for joins in certain queries, and play well with external systems already using those identifiers.
Disadvantages
But they come with trade-offs. Composite keys complicate Hibernate mappings, require extra ceremony (like equals()
and hashCode()
), and can be a nightmare if any part of the key needs to change. Unlike a stable surrogate id
, you can’t just update a composite key without affecting referential integrity.
Let’s Code: Enrollment Management with Composite Keys
We’ll build a simple Quarkus application that stores enrollments using studentId + courseCode
as a composite key. You’ll create entities, a REST API, and test everything end-to-end.
Prerequisites
Java 17+
Maven 3.8.2+
Quarkus CLI (optional, but helpful for project creation)
A database (H2 in-memory will be used for simplicity)
Create a Quarkus Project
First, let's create a new Quarkus project. Open your terminal or command prompt and run:
quarkus create app composite-key-app \
--extension='jdbc-h2, hibernate-orm-panache, rest-jackson'
This command creates a new Quarkus project named composite-key-app
and includes the necessary extensions for H2 database, Hibernate ORM with Panache, and REST endpoints with JSON support. It has a sample GreetingResource and MyEntity and example Tests. Feel free to delete them. (I know, but it’s really just a little, innocent tutorial!)
Also: If you don’t feel like copy&paste, head to my Github repository and take a look at the source.
Step 2: Define the Composite Key Class (EnrollmentId
)
In Hibernate/JPA, a composite primary key can be defined in a couple of ways: using an @Embeddable
class (which we'll use here) or by referencing an @IdClass
. For this tutorial, we will focus on the @Embeddable
approach, which encapsulates the key fields within a separate class.
This @Embeddable
class will represent our composite primary key and must adhere to specific JPA rules:
It must be
public
.It must have a no-argument constructor (JPA requirement).
It must correctly override
equals()
andhashCode()
methods. This is crucial for Hibernate to correctly manage entities with composite IDs, especially when entities are stored in collections (likeSet
) or used in caching.Modern JPA (3.2+) Note: While historically
java.io.Serializable
was a strict requirement for@Embeddable
classes, it is no longer mandatory with newer JPA specifications (like Jakarta Persistence 3.2) and compatible Hibernate versions. However, implementingSerializable
is still widely considered a best practice, especially if you plan to use features like JPA's second-level cache or transmit entity IDs across network boundaries.Using Java Records: Java Records, introduced in Java 16, are an excellent fit for
@Embeddable
classes. They automatically provide the constructor, getters,equals()
, andhashCode()
implementations, significantly reducing boilerplate code. This makes them a much simpler and cleaner way to define composite keys.
Create the file src/main/java/org/acme/EnrollmentId.java
:
package org.acme;
import java.io.Serializable;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
@Embeddable
public record EnrollmentId(
@Column(name = "student_id", nullable = false) String studentId,
@Column(name = "course_code", nullable = false) String courseCode
) implements Serializable {}
Step 3: Define the Entity with the Composite Key (Enrollment
)
Now, let's create the Enrollment
entity. We'll use Panache's PanacheEntityBase
as our base class because we are providing our own ID (the composite key), rather than extending PanacheEntity
(which provides a default Long id
surrogate primary key).
Create the file src/main/java/org/acme/Enrollment.java
:
package org.acme;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
@Entity
@Table(name = "enrollment")
public class Enrollment extends PanacheEntityBase {
@EmbeddedId
public EnrollmentId id;
public int grade;
public Enrollment() {
}
public Enrollment(EnrollmentId id, int grade) {
this.id = id;
this.grade = grade;
}
// Panache automatically generates getters/setters for public fields at compile time.
// However, you can add explicit getters/setters if you have custom logic or prefer a more
// traditional JPA entity structure. For this tutorial, we'll rely on Panache's magic for simplicity.
}
Explanation of Key Annotations:
@Entity
: Marks the class as a JPA entity, meaning it will be mapped to a database table.@Table(name = "enrollment")
: Explicitly specifies the name of the database table this entity maps to.@EmbeddedId
: This is the crucial annotation for composite keys. It tells Hibernate that theid
field (which is of typeEnrollmentId
) holds the embedded composite primary key for this entity. The columns defined withinEnrollmentId
(e.g.,student_id
,course_code
) will directly form the primary key of theenrollment
table.PanacheEntityBase
: We extend this class becausePanacheEntity
provides a defaultLong
ID, and we are managing our own custom composite ID.PanacheEntityBase
gives us access to Panache's convenient static methods (likelistAll()
,findById()
,persist()
, etc.) without dictating the ID type.
Step 4: Create a Resource (REST Endpoint) (EnrollmentResource
)
Let's create a REST endpoint to interact with our Enrollment
entity. This resource will provide operations for fetching, creating, updating, and deleting enrollments using their composite key.
Create the file src/main/java/org/acme/EnrollmentResource.java
:
package org.acme;
import java.util.List;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/enrollments")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class EnrollmentResource {
@GET
public List<Enrollment> getAll() {
return Enrollment.listAll();
}
@GET
@Path("/{studentId}/{courseCode}")
public Response get(@PathParam("studentId") String studentId,
@PathParam("courseCode") String courseCode) {
EnrollmentId id = new EnrollmentId(studentId, courseCode);
return Enrollment.findByIdOptional(id)
.map(Response::ok)
.orElse(Response.status(Response.Status.NOT_FOUND))
.build();
}
@POST
@Transactional
public Response create(EnrollmentDTO dto) {
EnrollmentId id = new EnrollmentId(dto.studentId, dto.courseCode);
if (Enrollment.findByIdOptional(id).isPresent()) {
return Response.status(Response.Status.CONFLICT)
.entity("Enrollment already exists.")
.build();
}
Enrollment enrollment = new Enrollment(id, dto.grade);
enrollment.persist();
return Response.status(Response.Status.CREATED).entity(enrollment).build();
}
@PUT
@Path("/{studentId}/{courseCode}")
@Transactional
public Response update(@PathParam("studentId") String studentId,
@PathParam("courseCode") String courseCode,
EnrollmentDTO dto) {
Enrollment enrollment = Enrollment.findById(new EnrollmentId(studentId, courseCode));
if (enrollment == null) {
return Response.status(Response.Status.NOT_FOUND).build();
}
enrollment.grade = dto.grade;
return Response.ok(enrollment).build();
}
@DELETE
@Path("/{studentId}/{courseCode}")
@Transactional
public Response delete(@PathParam("studentId") String studentId,
@PathParam("courseCode") String courseCode) {
boolean deleted = Enrollment.deleteById(new EnrollmentId(studentId, courseCode));
return deleted ? Response.noContent().build()
: Response.status(Response.Status.NOT_FOUND).build();
}
public record EnrollmentDTO(String studentId, String courseCode, int grade) {
}
}
Step 5: Configure application.properties
and import.sql
Configure the H2 in-memory database and enable Hibernate to generate the schema automatically. We'll also pre-populate some data using import.sql
.
Create or update the file src/main/resources/application.properties
:
# Hibernate ORM Configuration
quarkus.hibernate-orm.database.schema-management.strategy=drop-and-create
quarkus.hibernate-orm.log.sql=true
#quarkus.hibernate-orm.sql-load-script=import.sql
Now, update the src/main/resources/import.sql
file to pre-populate our database with some initial enrollment data:
-- Initial data for the enrollment table
INSERT INTO enrollment (student_id, course_code, grade) VALUES ('S001', 'CS101', 85);
INSERT INTO enrollment (student_id, course_code, grade) VALUES ('S001', 'MA201', 92);
INSERT INTO enrollment (student_id, course_code, grade) VALUES ('S002', 'CS101', 78);
Step 6: Run the Application
Open your terminal or command prompt in the composite-key-app
directory and run your Quarkus application in development mode:
quarkus dev
Quarkus will start, and you should see output indicating that Hibernate has created the enrollment
table and inserted the initial data from import.sql
. The application will be accessible at http://localhost:8080
Step 7: Test the Endpoints
You can use curl
(a command-line tool), Postman, Insomnia, or your preferred API client to test the created endpoints.
1. Get all enrollments:
curl -X GET http://localhost:8080/enrollments
Expected Output:
[
{
"id": {
"studentId": "S001",
"courseCode": "CS101"
},
"grade": 85
},
{
"id": {
"studentId": "S001",
"courseCode": "MA201"
},
"grade": 92
},
{
"id": {
"studentId": "S002",
"courseCode": "CS101"
},
"grade": 78
}
]
2. Get a specific enrollment by composite ID:
curl -X GET http://localhost:8080/enrollments/S001/CS101
Expected Output:
{
"id": {
"studentId": "S001",
"courseCode": "CS101"
},
"grade": 85
}
3. Create a new enrollment:
curl -X POST -H "Content-Type: application/json" -d '{"studentId": "S003", "courseCode": "PH301", "grade": 95}' http://localhost:8080/enrollments
Expected Output (Status 201 Created):
{
"id": {
"studentId": "S003",
"courseCode": "PH301"
},
"grade": 95
}
4. Attempt to create a duplicate enrollment (should fail with Conflict):
curl -X POST -H "Content-Type: application/json" -d '{"studentId": "S001", "courseCode": "CS101", "grade": 70}' http://localhost:8080/enrollments
Expected Output (Status 409 Conflict):
Enrollment for student S001 in course CS101 already exists.
5. Update an existing enrollment:
curl -X PUT -H "Content-Type: application/json" -d '{"studentId": "S001", "courseCode": "CS101", "grade": 90}' http://localhost:8080/enrollments/S001/CS101
Expected Output (Status 200 OK):
{
"id": {
"studentId": "S001",
"courseCode": "CS101"
},
"grade": 90
}
6. Delete an enrollment:
curl -X DELETE http://localhost:8080/enrollments/S002/CS101
Expected Output (Status 204 No Content).
You can then try GET http://localhost:8080/enrollments
again to confirm that S002/CS101
has been removed.
Important Considerations:
Immutability of
EnrollmentId
: While we included setters inEnrollmentId
for demonstration, it's generally good practice to make composite ID classes truly immutable by removing setters and only providing a constructor and getters. This prevents accidental modification of the key, which would violate its primary key integrity.Equals and HashCode are CRITICAL: As emphasized, the correct implementation of
equals()
andhashCode()
in your@Embeddable
key class (likeEnrollmentId
) is important. Without them, Hibernate cannot correctly identify entities, leading to unexpected behavior, including issues with data retrieval, caching, and persistence. Using Records gives this to you for free.Relationship Mapping with Composite Keys: When other entities in your application need to have foreign keys referencing an entity with a composite primary key, you'll need to define multiple
@JoinColumn
annotations within a@JoinColumns
wrapper annotation. Each@JoinColumn
will map one part of the composite foreign key to the corresponding part of the composite primary key. This adds more verbose and complex mapping compared to a single-column primary key.Java
// Example of mapping a foreign key to a composite primary key
@Entity
public class GradeReport {
@Id
@GeneratedValue
public Long id;
@ManyToOne
@JoinColumns({
@JoinColumn(name = "student_fk", referencedColumnName = "student_id"),
@JoinColumn(name = "course_fk", referencedColumnName = "course_code")
})
public Enrollment enrollment;
public String reportDetails;
}
Panache
findById
anddeleteById
for Composite Keys: Panache simplifies working with composite keys. For methods likefindById()
anddeleteById()
, you simply pass an instance of your@Embeddable
(or@IdClass
) key class directly, as demonstrated inEnrollmentResource
.Mutability of Business Keys vs. Surrogate Keys: This tutorial uses a business key. If
studentId
orcourseCode
were to legitimately change for an existing enrollment, it would be highly problematic. With a surrogate key, if business identifiers change, you only update the business identifier fields, leaving the stable surrogate primary key untouched. This is why surrogate keys are often preferred for their stability and simplicity in most modern applications. Use business keys when there's a strong, unchanging natural identifier or for integration with existing systems.
This tutorial provides a solid foundation for working with composite keys in Quarkus Panache. Remember to carefully evaluate the trade-offs before deciding to use business keys, especially considering the potential for mutability issues and increased complexity compared to simple surrogate keys.
make it so that Student has oneToMany relationship with Enrollment and at the same time Course has also oneToMany relationship with Enrollment