DEV Community

Markus
Markus

Posted on • Originally published at myfear.substack.com on

Composite Keys in Quarkus: Building Smarter IDs with Hibernate and Panache

Hibernate

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.

Thanks for reading Enterprise Java and Quarkus! Subscribe for free to receive new posts and support my work.

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'
Enter fullscreen mode Exit fullscreen mode

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() and hashCode() methods. This is crucial for Hibernate to correctly manage entities with composite IDs, especially when entities are stored in collections (like Set) 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, implementing Serializable 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(), and hashCode() 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 {}
Enter fullscreen mode Exit fullscreen mode

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.
}
Enter fullscreen mode Exit fullscreen mode

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 the id field (which is of type EnrollmentId) holds the embedded composite primary key for this entity. The columns defined within EnrollmentId (e.g., student_id, course_code) will directly form the primary key of the enrollment table.

  • PanacheEntityBase: We extend this class because PanacheEntity provides a default Long ID, and we are managing our own custom composite ID. PanacheEntityBase gives us access to Panache's convenient static methods (like listAll(), 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) {
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Expected Output:

[
  {
    "id": {
      "studentId": "S001",
      "courseCode": "CS101"
    },
    "grade": 85
  },
  {
    "id": {
      "studentId": "S001",
      "courseCode": "MA201"
    },
    "grade": 92
  },
  {
    "id": {
      "studentId": "S002",
      "courseCode": "CS101"
    },
    "grade": 78
  }
]
Enter fullscreen mode Exit fullscreen mode

2. Get a specific enrollment by composite ID:

curl -X GET http://localhost:8080/enrollments/S001/CS101
Enter fullscreen mode Exit fullscreen mode

Expected Output:

{
  "id": {
    "studentId": "S001",
    "courseCode": "CS101"
  },
  "grade": 85
}
Enter fullscreen mode Exit fullscreen mode

3. Create a new enrollment:

curl -X POST -H "Content-Type: application/json" -d '{"studentId": "S003", "courseCode": "PH301", "grade": 95}' http://localhost:8080/enrollments
Enter fullscreen mode Exit fullscreen mode

Expected Output (Status 201 Created):

{
  "id": {
    "studentId": "S003",
    "courseCode": "PH301"
  },
  "grade": 95
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Expected Output (Status 409 Conflict):

Enrollment for student S001 in course CS101 already exists.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Expected Output (Status 200 OK):

{
  "id": {
    "studentId": "S001",
    "courseCode": "CS101"
  },
  "grade": 90
}
Enter fullscreen mode Exit fullscreen mode

6. Delete an enrollment:

curl -X DELETE http://localhost:8080/enrollments/S002/CS101
Enter fullscreen mode Exit fullscreen mode

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 in EnrollmentId 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() and hashCode() in your @Embeddable key class (like EnrollmentId) 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.

// 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;
}
Enter fullscreen mode Exit fullscreen mode
  • Panache findById and deleteById for Composite Keys: Panache simplifies working with composite keys. For methods like findById() and deleteById(), you simply pass an instance of your @Embeddable (or @IdClass) key class directly, as demonstrated in EnrollmentResource.

  • Mutability of Business Keys vs. Surrogate Keys: This tutorial uses a business key. If studentId or courseCode 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.

Thanks for reading Enterprise Java and Quarkus! Subscribe for free to receive new posts and support my work.

Top comments (0)