JPA Composite vs. Surrogate Keys

Design JPA entities using composite and surrogate keys across 4 real-world scenarios.

Introduction

30 Second Summary

Every database table needs a way to uniquely identify each row. The choice you make for that identifier ripples through your entire schema, affecting how tables reference each other and how painful future migrations become.

In this project, you will build 4 progressively harder JPA entity scenarios that teach you exactly when to use composite keys, when to use surrogate keys, and how to read Hibernate-generated DDL to verify your decisions.

What You'll Build

A Spring Boot JPA project with 4 entity-relationship scenarios, each demonstrating a different facet of the composite-vs-surrogate key decision.

By the end of this project, you'll have:

  • 4 implemented entity scenarios demonstrating when to use composite keys, when to use surrogate keys, and when either is valid.
  • A personal decision-tree checklist for choosing between composite and surrogate keys on any future entity.
  • Hands-on experience reading Hibernate-generated DDL to understand the physical schema consequences of each key strategy.
  • Secret Mission: Apply your decision tree to a brand-new unseen scenario and defend your choice.

Are there any prerequisites?

You should be comfortable with basic Java and have written at least a simple Spring Boot entity class before. You also need MySQL running in Docker on your machine.

Before We Start

Before We Start...

Set Up the Project and Connect to MySQL

Before you can explore key strategies, you need a Spring Boot project wired to a database. Hibernate will generate the table schemas for you automatically, but only if it can connect to MySQL and has permission to log what it creates.

In this step, you will generate a Spring Boot project from Spring Initializr, configure it to connect to your Docker-hosted MySQL instance, and verify a clean startup.

In this step, get ready to:
  • Generate a Spring Boot 4.1.0 project with Spring Data JPA and MySQL Driver dependencies.
  • Configure Hibernate to output DDL to the console so you can inspect every CREATE TABLE statement.
  • Create the keystrategy database and verify the application starts cleanly.
Generate the Spring Boot project

Spring Initializr is a web tool that generates a ready-to-run project skeleton with the dependencies you choose. You will use it to create a Maven project pre-configured for JPA and MySQL.

  • Open your browser and navigate to https://start.spring.io.
  • Set **Project** to Maven.
  • Set **Language** to Java.
  • Set **Spring Boot** to 4.1.0.
  • Set **Group** to com.example.
  • Set **Artifact** to key-strategy.
  • Set **Packaging** to Jar.
  • Set **Java** to 25.
  • Click Dependencies and search for Spring Data JPA. Select it.
  • Search for MySQL Driver and select it.
  • Click Generate to download the ZIP file.

What does Spring Initializr give you?

The generated ZIP contains a complete Maven project structure with a pom.xml that includes Spring Data JPA and MySQL Connector/J as managed dependencies. It also includes a pre-built application class (KeyStrategyApplication.java) and an empty application.properties file ready for your configuration.

  • Extract the downloaded key-strategy.zip to your Desktop.
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type IntelliJ and press Enter to open it.
  • In IntelliJ, click File in the top menu bar.
  • Click Open.
  • Navigate to the extracted key-strategy folder on your Desktop and click OK.
  • If IntelliJ asks whether to trust the project, click Trust Project.

IntelliJ shows 'Importing project' for a long time?

Maven needs to download the project dependencies on first open. This can take a minute or two depending on your internet speed. Check the bottom status bar for progress.

Why is IntelliJ stuck importing my Maven project?

Configure MySQL connection and Hibernate DDL output

Hibernate needs to know where your database lives and how to authenticate. You also need to tell it to recreate tables on every startup and print the generated SQL so you can inspect each schema.

  • In the IntelliJ Project panel on the left, expand src/main/resources.
  • Open application.properties.
  • Replace the empty file contents with the following configuration:
spring.datasource.url=jdbc:mysql://localhost:3306/keystrategy
spring.datasource.username=root
spring.datasource.password=your-mysql-password-here
spring.jpa.hibernate.ddl-auto=create
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

What does each property do?

  • spring.datasource.url tells Spring where to find your MySQL database. Port 3306 is MySQL's default.
  • spring.jpa.hibernate.ddl-auto=create tells Hibernate to drop and recreate all tables on every startup. This lets you see fresh DDL output each time you run.
  • spring.jpa.show-sql=true prints every SQL statement Hibernate executes to the console.
  • spring.jpa.properties.hibernate.format_sql=true formats those SQL statements with line breaks so they are easy to read.
  • Replace your-mysql-password-here with the root password you set when you started your MySQL Docker container.
  • Save the file with Cmd+S (macOS) or Ctrl+S (Windows).

Now create the database that this connection points to. The database does not exist yet inside your MySQL container.

  • Open a terminal on your machine.
  • Create the keystrategy database by running this command:
docker exec -it [[MYSQL_CONTAINER="your MySQL container name"]] mysql -uroot -p -e "CREATE DATABASE keystrategy;"

What does this command do?

  • docker exec -it runs a command inside your running Docker container with an interactive terminal.
  • mysql -uroot -p launches the MySQL client as the root user. The -p flag tells MySQL to prompt you for your password.
  • -e "CREATE DATABASE keystrategy;" executes the SQL statement and exits.
  • When prompted, enter your MySQL root password and press Enter.

You should see no error output. The command returns silently on success.

Seeing 'Error response from daemon: No such container'?

This means the container name you used does not match a running container. Run docker ps to list your running containers and find the correct name in the NAMES column.

Help me find and connect to my MySQL Docker container.

✔️ Awesome, I've got everything!

Great. Make sure you have saved application.properties with your actual MySQL password filled in.

ⓧ I'd like to double check the full code

Here is the complete application.properties file:

spring.datasource.url=jdbc:mysql://localhost:3306/keystrategy
spring.datasource.username=root
spring.datasource.password=your-mysql-password-here
spring.jpa.hibernate.ddl-auto=create
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

And here is the auto-generated application class (you should not need to modify this):

package com.example.keystrategy;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class KeyStrategyApplication {

    public static void main(String[] args) {
        SpringApplication.run(KeyStrategyApplication.class, args);
    }
}
Verify the application starts

With the database created and connection configured, you can confirm everything is wired correctly by running the application.

  • In IntelliJ, open KeyStrategyApplication.java from the Project panel (src/main/java/com/example/keystrategy).
  • Click the green play button in the gutter next to the main method, then select Run 'KeyStrategyApplication'.

Watch the console at the bottom of IntelliJ. You should see a line containing Started KeyStrategyApplication with no errors.

What should I see?

The console output includes the Hibernate dialect being selected (confirming it connected to MySQL) and the application startup banner. Because you have no entity classes yet, Hibernate will not create any tables. That is expected.

Seeing 'Communications link failure' or 'Access denied'?

  • If you see a communications link failure, make sure your MySQL Docker container is running. Run docker ps and confirm the container is listed and port 3306 is mapped.
  • If you see access denied, double-check that the password in application.properties matches the root password you set when creating the MySQL container.
  • If you see a database not found error, confirm you ran the CREATE DATABASE keystrategy command successfully in the previous substep.

Help me fix my Spring Boot MySQL connection error.

Your project skeleton is ready and talking to MySQL. Next up, you will build your first entity scenario and see Hibernate generate a real CREATE TABLE statement with a composite primary key.

Scenario 1 - The Clear Composite Key Case

Your project is connected to MySQL and starting cleanly. Now it is time to model your first entity-relationship scenario and see how Hibernate translates your JPA annotations into actual DDL.

Scenario 1 asks: when a row in a join table has absolutely no identity beyond the combination of its two foreign keys, what key strategy should you use? You will model a Student-Course enrollment system and implement a composite primary key to find out.

In this step, get ready to:
  • Design a Student-Course enrollment domain where the join entity has no independent identity.
  • Implement entities using @EmbeddedId and @MapsId to create a composite primary key.
  • Run the application and inspect the Hibernate-generated DDL to confirm the composite key structure.
Think before you code

Before writing any code, answer this question in your own head:

Reflection question

If a row in a join table has NO identity beyond the combination of its two foreign keys, and NO other table will ever need a foreign key pointing to this specific row, what does that tell you about the key strategy?

Hold your answer. You will validate it against the DDL output at the end of this step.

Design the domain

You are modeling a Student-Course enrollment system. An Enrollment is a join entity between Student and Course with extra fields: enrollmentDate and grade.

The enrollment has no meaning outside the student-course pair. Nothing else in the schema references an enrollment row directly.

Why does this matter for key strategy?

When a row's identity IS the combination of its foreign keys, a composite primary key is the natural fit. It gives you automatic uniqueness enforcement (a student cannot enroll in the same course twice) without needing an extra unique constraint.

You will create four files inside a new scenario1 package:

  • Student.java and Course.java as simple entities with surrogate keys.
  • EnrollmentId.java as the embeddable composite key class.
  • Enrollment.java using @EmbeddedId and @MapsId to wire it all together.
Implement the entities

Start by creating the new package, then add each entity file inside it.

  • In the IntelliJ Project tool window, expand src/main/java and right-click the com.example.keystrategy package.
  • Select New then Package.
  • Type scenario1 and press Enter.

Now create the first two entities. These are the parent tables that the composite key will reference.

  • Right-click the new scenario1 package and select New then Java Class.
  • Name it Student and press Enter.
  • Replace the generated content with the following code:
package com.example.keystrategy.scenario1;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "s1_student")
public class Student {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    public Student() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
}

What does this code do?

This is a straightforward JPA entity with a surrogate key. @GeneratedValue(strategy = GenerationType.IDENTITY) tells MySQL to auto-increment the id column.

The @Table(name = "s1_student") prefix keeps this scenario's tables separate from other scenarios in the same database.

  • Create another Java class in the scenario1 package named Course.
  • Replace its content with:
package com.example.keystrategy.scenario1;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "s1_course")
public class Course {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    public Course() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }
}

What does this code do?

Same pattern as Student. A simple entity with an auto-generated surrogate key. Course stores a title field and maps to the s1_course table.

  • Save both Student.java and Course.java.

You will not see any visible output yet. These parent entities provide the foreign key targets that the composite key needs.

Next, create the composite key class. This is the heart of Scenario 1.

  • Create a new Java class in scenario1 named EnrollmentId.
  • Replace its content with:
package com.example.keystrategy.scenario1;

import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import java.io.Serializable;
import java.util.Objects;

@Embeddable
public class EnrollmentId implements Serializable {

    @Column(name = "student_id")
    private Long studentId;

    @Column(name = "course_id")
    private Long courseId;

    public EnrollmentId() {}

    public EnrollmentId(Long studentId, Long courseId) {
        this.studentId = studentId;
        this.courseId = courseId;
    }

    public Long getStudentId() { return studentId; }
    public void setStudentId(Long studentId) { this.studentId = studentId; }
    public Long getCourseId() { return courseId; }
    public void setCourseId(Long courseId) { this.courseId = courseId; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof EnrollmentId that)) return false;
        return Objects.equals(studentId, that.studentId) &&
               Objects.equals(courseId, that.courseId);
    }

    @Override
    public int hashCode() {
        return Objects.hash(studentId, courseId);
    }
}

What does this code do?

This class IS the composite primary key. @Embeddable tells JPA that this class will be embedded inside another entity as its identifier.

JPA requires composite key classes to implement Serializable, have a no-arg constructor, and override equals() and hashCode(). Without these, Hibernate cannot compare or cache entity identities correctly.

Column names must match

The @Column(name = "student_id") annotations define the physical column names that will appear in the DDL. These must match the @JoinColumn names in the entity that uses this key.

  • Save EnrollmentId.java.

No visible output yet. The final entity below ties everything together.

Now create the Enrollment entity that uses the composite key.

  • Create a new Java class in scenario1 named Enrollment.
  • Replace its content with:
package com.example.keystrategy.scenario1;

import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.MapsId;
import jakarta.persistence.Table;
import java.time.LocalDate;

@Entity
@Table(name = "s1_enrollment")
public class Enrollment {

    @EmbeddedId
    private EnrollmentId id;

    @ManyToOne
    @MapsId("studentId")
    @JoinColumn(name = "student_id")
    private Student student;

    @ManyToOne
    @MapsId("courseId")
    @JoinColumn(name = "course_id")
    private Course course;

    private LocalDate enrollmentDate;

    private String grade;

    public Enrollment() {}

    public Enrollment(Student student, Course course) {
        this.id = new EnrollmentId(student.getId(), course.getId());
        this.student = student;
        this.course = course;
    }

    public EnrollmentId getId() { return id; }
    public void setId(EnrollmentId id) { this.id = id; }
    public Student getStudent() { return student; }
    public void setStudent(Student student) { this.student = student; }
    public Course getCourse() { return course; }
    public void setCourse(Course course) { this.course = course; }
    public LocalDate getEnrollmentDate() { return enrollmentDate; }
    public void setEnrollmentDate(LocalDate enrollmentDate) { this.enrollmentDate = enrollmentDate; }
    public String getGrade() { return grade; }
    public void setGrade(String grade) { this.grade = grade; }
}

What does this code do?

This is where the composite key pattern comes together. @EmbeddedId tells JPA that the EnrollmentId object IS this entity's primary key.

The @MapsId("studentId") annotation on the @ManyToOne relationship tells Hibernate that the studentId field in the embedded key should be derived from this relationship's foreign key. This creates a derived identifier where the entity's identity comes directly from its parent relationships.

The convenience constructor initializes the composite key manually. Hibernate requires this because the identity is derived from the parent references.

  • Save Enrollment.java.

Seeing red underlines in IntelliJ?

Make sure all four files are in the same com.example.keystrategy.scenario1 package. If Enrollment.java cannot find EnrollmentId, the file may be in the wrong package.

Also confirm your imports use jakarta.persistence (not javax.persistence). Spring Boot 4.1.0 uses Hibernate 7.x which requires the Jakarta namespace.

Help me debug my entity mapping errors

✔️ Awesome, I've got everything!

Great. Make sure all four files are saved before running the application.

ⓧ I'd like to double check the full code

Here are all four files for Scenario 1:

package com.example.keystrategy.scenario1;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "s1_student")
public class Student {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    public Student() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
}
package com.example.keystrategy.scenario1;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "s1_course")
public class Course {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    public Course() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }
}
package com.example.keystrategy.scenario1;

import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import java.io.Serializable;
import java.util.Objects;

@Embeddable
public class EnrollmentId implements Serializable {

    @Column(name = "student_id")
    private Long studentId;

    @Column(name = "course_id")
    private Long courseId;

    public EnrollmentId() {}

    public EnrollmentId(Long studentId, Long courseId) {
        this.studentId = studentId;
        this.courseId = courseId;
    }

    public Long getStudentId() { return studentId; }
    public void setStudentId(Long studentId) { this.studentId = studentId; }
    public Long getCourseId() { return courseId; }
    public void setCourseId(Long courseId) { this.courseId = courseId; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof EnrollmentId that)) return false;
        return Objects.equals(studentId, that.studentId) &&
               Objects.equals(courseId, that.courseId);
    }

    @Override
    public int hashCode() {
        return Objects.hash(studentId, courseId);
    }
}
package com.example.keystrategy.scenario1;

import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.MapsId;
import jakarta.persistence.Table;
import java.time.LocalDate;

@Entity
@Table(name = "s1_enrollment")
public class Enrollment {

    @EmbeddedId
    private EnrollmentId id;

    @ManyToOne
    @MapsId("studentId")
    @JoinColumn(name = "student_id")
    private Student student;

    @ManyToOne
    @MapsId("courseId")
    @JoinColumn(name = "course_id")
    private Course course;

    private LocalDate enrollmentDate;

    private String grade;

    public Enrollment() {}

    public Enrollment(Student student, Course course) {
        this.id = new EnrollmentId(student.getId(), course.getId());
        this.student = student;
        this.course = course;
    }

    public EnrollmentId getId() { return id; }
    public void setId(EnrollmentId id) { this.id = id; }
    public Student getStudent() { return student; }
    public void setStudent(Student student) { this.student = student; }
    public Course getCourse() { return course; }
    public void setCourse(Course course) { this.course = course; }
    public LocalDate getEnrollmentDate() { return enrollmentDate; }
    public void setEnrollmentDate(LocalDate enrollmentDate) { this.enrollmentDate = enrollmentDate; }
    public String getGrade() { return grade; }
    public void setGrade(String grade) { this.grade = grade; }
}
Run and inspect the DDL

With all four entity files saved, it is time to let Hibernate generate the schema and confirm the composite key is working.

  • Run the application by clicking the green play button next to the main method in KeyStrategyApplication.java, or press Ctrl+R (macOS) / Shift+F10 (Windows).
  • Watch the console output. Hibernate will print formatted SQL DDL statements as it creates the tables.
  • Look for the s1_enrollment table's CREATE TABLE statement in the console.

You should see that s1_enrollment has a PRIMARY KEY (student_id, course_id) with no auto-generated id column. The table also has foreign key constraints pointing to s1_student and s1_course.

What should I see?

Your console will show three CREATE TABLE statements: one for s1_student, one for s1_course, and one for s1_enrollment.

The enrollment table will have only student_id, course_id, enrollment_date, and grade columns. There is no id column because the composite key IS the primary key.

Application fails to start?

If you see a mapping error, double-check that the @MapsId value matches the field name inside EnrollmentId exactly. For example, @MapsId("studentId") must correspond to the studentId field (not student_id).

If MySQL refuses the connection, confirm your Docker container is running and the keystrategy database exists.

Help me debug my Hibernate mapping error

Why a composite key is the right choice here

This is the textbook composite key case. The row's identity IS the combination of student + course. A composite key gives you automatic uniqueness enforcement (a student cannot enroll in the same course twice) without needing an extra unique constraint.

Since nothing else references this row via a foreign key, the main downside of composite keys (wide, fragile FKs in referencing tables) does not apply here.

You have just seen the clearest case for a composite key: a join entity whose identity is entirely derived from its parent relationships. In the next step, you will encounter a scenario that looks almost identical at first glance but requires a completely different key strategy.

Scenario 2 - Looks Like Composite, Needs Surrogate

In Scenario 1, the enrollment entity had no identity beyond the combination of its two foreign keys. No other table needed to reference an enrollment row directly, making it a clean composite key case.

But what happens when another entity needs a foreign key pointing directly at your join entity? This scenario models an Order-OrderItem-Refund system that reveals when a composite key creates real problems for other tables in your schema.

In this step, get ready to:
  • Create the scenario2 package with ProductOrder, Product, OrderItem, and Refund entities.
  • Implement a surrogate key with @UniqueConstraint to preserve business rules while enabling clean FK references.
  • Verify the DDL shows a surrogate PK on OrderItem and a single-column FK from Refund.
Design the domain

Consider this question before you start implementing: if another entity needs a foreign key pointing directly at THIS specific row, what problem does a composite key create for that referencing entity?

The referenceability problem

When table B has a foreign key pointing at table A, table B must carry all columns that make up table A's primary key. If table A uses a composite key of (order_id, product_id), then every table referencing it must store BOTH columns as its FK. This creates wide, fragile foreign keys.

In this scenario, a Refund entity needs to point at a specific OrderItem. If OrderItem used a composite key, the refund table would need both order_id and product_id just to reference one row. A surrogate key solves this by giving each row a single-column stable address.

Here is the domain you are modeling: an Order contains OrderItems, each referencing a Product. A Refund entity needs to reference a specific OrderItem row directly via a foreign key.

  • In IntelliJ, right-click the com.example.keystrategy package in the Project sidebar.
  • Select New, then Package.
  • Name it scenario2 and press Enter.
Implement with surrogate key and unique constraint

You will create four entity classes in the scenario2 package. The two supporting entities (ProductOrder and Product) are straightforward. The interesting design decisions live in OrderItem and Refund.

  • Create a new Java class in the scenario2 package called ProductOrder.java and paste the following code:
package com.example.keystrategy.scenario2;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;

// Represents a customer order with a timestamp
@Entity
@Table(name = "s2_product_order")
public class ProductOrder {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private LocalDateTime orderDate;

    public ProductOrder() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public LocalDateTime getOrderDate() { return orderDate; }
    public void setOrderDate(LocalDateTime orderDate) { this.orderDate = orderDate; }
}

Why ProductOrder instead of Order?

"Order" is a reserved word in SQL. Using ProductOrder avoids quoting issues when Hibernate generates the DDL. The table is mapped to s2_product_order to prevent name collisions with other scenarios in the same database.

  • Create another Java class in the scenario2 package called Product.java and paste the following code:
package com.example.keystrategy.scenario2;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

// Represents a purchasable product
@Entity
@Table(name = "s2_product")
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    public Product() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
}

What does this entity do?

Product is a simple entity with a surrogate key, identical in pattern to Student and Course from Scenario 1. It exists so that OrderItem can reference it via a @ManyToOne relationship.

Now for the key entity in this scenario. OrderItem looks like a composite key case at first glance because it sits between an order and a product. But the Refund entity needs to point at a specific OrderItem row with a single FK column.

  • Create OrderItem.java in the scenario2 package and paste the following code:
package com.example.keystrategy.scenario2;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;

// Uses a surrogate key so other entities can reference it with a single FK
// The @UniqueConstraint preserves the business rule: one item per product per order
@Entity
@Table(name = "s2_order_item", uniqueConstraints = {
    @UniqueConstraint(columnNames = {"order_id", "product_id"})
})
public class OrderItem {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "order_id")
    private ProductOrder order;

    @ManyToOne
    @JoinColumn(name = "product_id")
    private Product product;

    private int quantity;

    private double unitPrice;

    public OrderItem() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public ProductOrder getOrder() { return order; }
    public void setOrder(ProductOrder order) { this.order = order; }
    public Product getProduct() { return product; }
    public void setProduct(Product product) { this.product = product; }
    public int getQuantity() { return quantity; }
    public void setQuantity(int quantity) { this.quantity = quantity; }
    public double getUnitPrice() { return unitPrice; }
    public void setUnitPrice(double unitPrice) { this.unitPrice = unitPrice; }
}

What does this code do?

This is the critical difference from Scenario 1. Instead of a composite key, OrderItem uses a surrogate @Id with @GeneratedValue. Each row gets its own auto-generated numeric identity.

The @UniqueConstraint on (order_id, product_id) enforces the same business rule a composite key would: a product cannot appear twice in the same order. But now the row has a single-column stable address that other tables can reference cleanly.

Notice the columnNames array references the physical column names defined in @JoinColumn, not the Java field names.

  • Create Refund.java in the scenario2 package and paste the following code:
package com.example.keystrategy.scenario2;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDateTime;

// References OrderItem with a single FK column (order_item_id)
// This is only possible because OrderItem uses a surrogate key
@Entity
@Table(name = "s2_refund")
public class Refund {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // A single FK column references the OrderItem's surrogate id
    @ManyToOne
    @JoinColumn(name = "order_item_id")
    private OrderItem orderItem;

    private double amount;

    private String reason;

    private LocalDateTime refundDate;

    public Refund() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public OrderItem getOrderItem() { return orderItem; }
    public void setOrderItem(OrderItem orderItem) { this.orderItem = orderItem; }
    public double getAmount() { return amount; }
    public void setAmount(double amount) { this.amount = amount; }
    public String getReason() { return reason; }
    public void setReason(String reason) { this.reason = reason; }
    public LocalDateTime getRefundDate() { return refundDate; }
    public void setRefundDate(LocalDateTime refundDate) { this.refundDate = refundDate; }
}

Why does this prove the surrogate key decision?

Refund references OrderItem with a single column: order_item_id. If OrderItem had used a composite key of (order_id, product_id), the refund table would need BOTH columns as its FK. That means wider joins, more fragile references, and cascading changes if the composite key ever evolved.

  • Save all four files: ProductOrder.java, Product.java, OrderItem.java, and Refund.java.

✔️ Awesome, I've got everything!

Great. Make sure all four files are saved before running the application in the next substep.

ⓧ I'd like to double check the full code

Here are all four scenario2 entity files for comparison.

package com.example.keystrategy.scenario2;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;

@Entity
@Table(name = "s2_product_order")
public class ProductOrder {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private LocalDateTime orderDate;

    public ProductOrder() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public LocalDateTime getOrderDate() { return orderDate; }
    public void setOrderDate(LocalDateTime orderDate) { this.orderDate = orderDate; }
}
package com.example.keystrategy.scenario2;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "s2_product")
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    public Product() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
}
package com.example.keystrategy.scenario2;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;

@Entity
@Table(name = "s2_order_item", uniqueConstraints = {
    @UniqueConstraint(columnNames = {"order_id", "product_id"})
})
public class OrderItem {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "order_id")
    private ProductOrder order;

    @ManyToOne
    @JoinColumn(name = "product_id")
    private Product product;

    private int quantity;

    private double unitPrice;

    public OrderItem() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public ProductOrder getOrder() { return order; }
    public void setOrder(ProductOrder order) { this.order = order; }
    public Product getProduct() { return product; }
    public void setProduct(Product product) { this.product = product; }
    public int getQuantity() { return quantity; }
    public void setQuantity(int quantity) { this.quantity = quantity; }
    public double getUnitPrice() { return unitPrice; }
    public void setUnitPrice(double unitPrice) { this.unitPrice = unitPrice; }
}
package com.example.keystrategy.scenario2;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDateTime;

@Entity
@Table(name = "s2_refund")
public class Refund {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "order_item_id")
    private OrderItem orderItem;

    private double amount;

    private String reason;

    private LocalDateTime refundDate;

    public Refund() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public OrderItem getOrderItem() { return orderItem; }
    public void setOrderItem(OrderItem orderItem) { this.orderItem = orderItem; }
    public double getAmount() { return amount; }
    public void setAmount(double amount) { this.amount = amount; }
    public String getReason() { return reason; }
    public void setReason(String reason) { this.reason = reason; }
    public LocalDateTime getRefundDate() { return refundDate; }
    public void setRefundDate(LocalDateTime refundDate) { this.refundDate = refundDate; }
}
Run and inspect the DDL

With all four entities saved, run the application so Hibernate generates the DDL for your new tables alongside the Scenario 1 tables.

  • Click the green Run button in the IntelliJ toolbar (or press Shift+F10 on Windows / Ctrl+R on macOS).
  • In the console output, look for the CREATE TABLE statements prefixed with s2_.

You should see three important things in the DDL for s2_order_item:

  • A surrogate primary key column id with auto-increment. No composite PK.
  • A unique constraint on (order_id, product_id) enforcing the business rule.
  • The s2_refund table with a single FK column order_item_id pointing at s2_order_item.id.

What should I see?

The s2_order_item CREATE TABLE will show id bigint not null auto_increment as the primary key, with a separate unique constraint line for the (order_id, product_id) pair. Compare this with Scenario 1's s1_enrollment which had primary key (student_id, course_id) directly.

The s2_refund table will have order_item_id bigint as a single FK column. This clean reference is only possible because OrderItem uses a surrogate key.

Application fails to start?

Make sure all four files are in the com.example.keystrategy.scenario2 package. If a class is in the wrong package, Hibernate will not scan it.

Check that the columnNames in @UniqueConstraint use the physical column names (order_id, product_id) matching the @JoinColumn name attributes. Using the Java field names (order, product) will cause a mapping exception.

help me debug my Scenario 2 entity startup error

The key insight from Scenario 2

The moment another entity needs to point at your row with a FK, a composite key forces that referencing table to carry ALL columns of the composite key. This creates fragile, wide foreign keys that break if the composite key ever changes.

A surrogate key gives each row a stable, single-column address that any other table can reference cleanly. The @UniqueConstraint still enforces the business rule (one item per product per order) without baking that rule into the primary key itself.

You now have a clear example of when a composite key breaks down. Next up, you will explore an even sneakier scenario where the business rules themselves change over time and break a composite key from within.

Scenario 3 - Future Requirements Break Composite Keys

Your Scenario 2 implementation proved that surrogate keys solve the referenceability problem. When another entity needs to point at your row, a single-column FK is cleaner than carrying an entire composite key.

But what happens when the business rule that defines your composite key changes? In this scenario, you will model an attendance system where today's uniqueness rule is guaranteed to evolve. You will see how a surrogate key lets you adapt without touching the primary key or any foreign keys that depend on it.

In this step, get ready to:
  • Design a Student-Attendance domain where the uniqueness rule will evolve.
  • Implement with a surrogate key and a modifiable unique constraint.
  • Demonstrate that changing the business rule only touches the constraint, not the primary key.
Design the domain

Before writing any code, answer this reflection question in your head or on paper:

Reflection question

If a business rule that defines your composite key CHANGES (e.g., from "one per day" to "multiple per day"), what happens to your primary key and all tables that reference it?

Here is the scenario. A school tracks attendance with this business rule today: "One attendance record per student per class per day." This looks like a textbook composite key: (student_id, class_id, attendance_date).

But the school is planning to allow multiple attendance records per day in the future (morning session, afternoon session). If attendance_date is baked into the primary key, that future requirement forces a primary key migration across every table that references it.

  • In the IntelliJ Project panel on the left, right-click on com.example.keystrategy.
  • Select New, then Package.
  • Name the package scenario3.

You will create three files in this package: AttendanceStudent.java, ClassRoom.java, and Attendance.java.

Implement the supporting entities

The attendance domain needs two reference entities: a student and a classroom. These follow the same simple surrogate key pattern you used in Scenarios 1 and 2.

  • Right-click on the scenario3 package and select New, then Java Class.
  • Name it AttendanceStudent and paste the following code:
package com.example.keystrategy.scenario3;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "s3_student")
public class AttendanceStudent {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    public AttendanceStudent() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
}

What does this code do?

This is a standard surrogate-key entity mapped to s3_student. The s3_ prefix prevents collisions with the s1_student table from Scenario 1.

  • Save AttendanceStudent.java.
  • Create another new Java class in the scenario3 package named ClassRoom and paste the following code:
package com.example.keystrategy.scenario3;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "s3_classroom")
public class ClassRoom {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    public ClassRoom() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
}

What does this code do?

Same pattern as AttendanceStudent. A simple entity mapped to s3_classroom with a surrogate id and a name field. These two entities exist so that Attendance can hold relationships to them.

  • Save ClassRoom.java.
Implement Attendance with a surrogate key anticipating change

The Attendance entity is where the key strategy decision matters. Instead of making (student_id, classroom_id, attendance_date) the primary key, you will use a surrogate @Id and enforce today's business rule with a @UniqueConstraint.

  • Create a new Java class in the scenario3 package named Attendance and paste the following code:
package com.example.keystrategy.scenario3;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.LocalDate;

@Entity
@Table(name = "s3_attendance", uniqueConstraints = {
    @UniqueConstraint(columnNames = {"student_id", "classroom_id", "attendance_date"})
})
public class Attendance {

    // Surrogate key - decoupled from business rules
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "student_id")
    private AttendanceStudent student;

    @ManyToOne
    @JoinColumn(name = "classroom_id")
    private ClassRoom classRoom;

    private LocalDate attendanceDate;

    // Not part of the unique constraint yet - ready for the future
    private String session;

    private boolean present;

    public Attendance() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public AttendanceStudent getStudent() { return student; }
    public void setStudent(AttendanceStudent student) { this.student = student; }
    public ClassRoom getClassRoom() { return classRoom; }
    public void setClassRoom(ClassRoom classRoom) { this.classRoom = classRoom; }
    public LocalDate getAttendanceDate() { return attendanceDate; }
    public void setAttendanceDate(LocalDate attendanceDate) { this.attendanceDate = attendanceDate; }
    public String getSession() { return session; }
    public void setSession(String session) { this.session = session; }
    public boolean isPresent() { return present; }
    public void setPresent(boolean present) { this.present = present; }
}

What does this code do?

The @Id with @GeneratedValue gives each row its own stable identity that never needs to change. The primary key is completely independent of the business data.

The @UniqueConstraint on (student_id, classroom_id, attendance_date) enforces today's rule: one attendance record per student per class per day. This constraint lives separately from the PK, so you can modify it without a primary key migration.

The session field is already in the entity but deliberately excluded from the unique constraint. When the "multiple per day" requirement arrives, you only need to add session to the constraint columns.

  • Save Attendance.java.

Seeing a red underline on the imports?

Make sure your import statements use jakarta.persistence (not javax.persistence). Spring Boot 4.1.0 uses the Jakarta namespace exclusively.

help me fix import errors in my JPA entity class

Run and inspect the DDL

With all three entities saved, run the application to see what Hibernate generates for the s3_attendance table.

  • Run the application by clicking the green play button in IntelliJ or pressing Shift+F10 (Windows) or Ctrl+R (macOS).
  • In the console output, look for the create table s3_attendance statement.

You should see that s3_attendance has a surrogate id column as the PRIMARY KEY, plus a separate UNIQUE constraint on (student_id, classroom_id, attendance_date). The session column exists in the table but is not part of any constraint.

What should I see?

The DDL output shows the primary key and unique constraint as separate concerns. The PK is the auto-generated id column. The business rule enforcement is a UNIQUE KEY on three columns. These two things are completely decoupled from each other.

Now, simulate the future requirement change. You will add session to the unique constraint to allow multiple attendance records per day (one per session).

  • In Attendance.java, find this line near the top of the class:
    @UniqueConstraint(columnNames = {"student_id", "classroom_id", "attendance_date"})
  • Replace that line with the following, adding "session" to the column names array:
    @UniqueConstraint(columnNames = {"student_id", "classroom_id", "attendance_date", "session"})
  • Save the file and re-run the application.
  • In the console, find the create table s3_attendance statement again.

The PRIMARY KEY is still the same surrogate id column. Only the UNIQUE constraint changed to include the fourth column. No primary key migration. No foreign key rewrites.

Why does this matter?

Composite keys bake business rules into the physical identity of the row. When those rules change, you face a primary key migration. That is one of the most painful schema changes possible, requiring cascading updates to every referencing foreign key.

Surrogate keys decouple identity from business rules. The unique constraint enforces today's rules, and changing it is a simple operation. The PK and all FKs remain untouched.

  • Revert the unique constraint back to the original 3-column version (without "session") so your code matches the current business rule:
    @UniqueConstraint(columnNames = {"student_id", "classroom_id", "attendance_date"})
  • Save Attendance.java.

Application won't start after re-run?

Since ddl-auto=create drops and recreates tables on each startup, make sure you stopped the previous run before starting a new one. IntelliJ shows a red stop button in the top toolbar.

Also confirm the columnNames array uses the physical column names from @JoinColumn (like student_id), not the Java field names (like student).

help me debug my Attendance entity startup error

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files in the scenario3 package with the original 3-column unique constraint.

ⓧ I'd like to double check the full code

Here is the final version of each file in the scenario3 package:

package com.example.keystrategy.scenario3;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "s3_student")
public class AttendanceStudent {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    public AttendanceStudent() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
}
package com.example.keystrategy.scenario3;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "s3_classroom")
public class ClassRoom {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    public ClassRoom() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
}
package com.example.keystrategy.scenario3;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.LocalDate;

@Entity
@Table(name = "s3_attendance", uniqueConstraints = {
    @UniqueConstraint(columnNames = {"student_id", "classroom_id", "attendance_date"})
})
public class Attendance {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "student_id")
    private AttendanceStudent student;

    @ManyToOne
    @JoinColumn(name = "classroom_id")
    private ClassRoom classRoom;

    private LocalDate attendanceDate;

    private String session;

    private boolean present;

    public Attendance() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public AttendanceStudent getStudent() { return student; }
    public void setStudent(AttendanceStudent student) { this.student = student; }
    public ClassRoom getClassRoom() { return classRoom; }
    public void setClassRoom(ClassRoom classRoom) { this.classRoom = classRoom; }
    public LocalDate getAttendanceDate() { return attendanceDate; }
    public void setAttendanceDate(LocalDate attendanceDate) { this.attendanceDate = attendanceDate; }
    public String getSession() { return session; }
    public void setSession(String session) { this.session = session; }
    public boolean isPresent() { return present; }
    public void setPresent(boolean present) { this.present = present; }
}

You have now seen the strongest argument for surrogate keys in schemas that evolve. Changing a unique constraint is trivial. Changing a primary key is a nightmare. Next up, you will tackle a genuinely ambiguous scenario where both approaches are valid and you get to make the call yourself.

Scenario 4 - Both Valid, You Decide

In the first three scenarios, one key strategy was clearly better than the other. Scenario 1 was a textbook composite key case. Scenarios 2 and 3 demonstrated why surrogate keys win when referenceability or future change is involved.

This scenario is different. You will model a Product-Review system where both approaches are technically valid. By implementing both side by side and comparing the DDL that Hibernate generates, you will see the concrete physical differences and make your own informed decision.

In this step, get ready to:
  • Implement both a composite key and surrogate key version of the same entity.
  • Compare Hibernate-generated DDL side by side to see physical schema differences.
  • Write a personal decision-tree checklist consolidating everything from all four scenarios.

Reflection question

Before you implement, consider: when both composite and surrogate keys are technically correct, what factors tip the scale? Think about referenceability, query patterns, storage overhead, and schema evolution.

Design the domain and implement both versions

The business rule is simple: one review per user per product. No other entity references a review row. No foreseeable requirement change.

  • In IntelliJ IDEA, right-click the com.example.keystrategy package in the Project tool window.
  • Select New then Package.
  • Name the package scenario4.

First, create the two supporting entities that reviews will reference.

  • Create a new file ReviewUser.java inside the scenario4 package with the following code:
package com.example.keystrategy.scenario4;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "s4_user")
public class ReviewUser {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String username;

    public ReviewUser() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }
}

What does this code do?

This is a simple user entity mapped to s4_user. The s4_ prefix keeps Scenario 4 tables visually distinct from the other scenarios in your database.

  • Save ReviewUser.java.
  • Create a new file ReviewProduct.java in the same scenario4 package with the following code:
package com.example.keystrategy.scenario4;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "s4_product")
public class ReviewProduct {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    public ReviewProduct() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
}

What does this code do?

Another simple supporting entity mapped to s4_product. Both ReviewUser and ReviewProduct use surrogate keys because they are standalone entities. The interesting decision is on the review entity that sits between them.

  • Save ReviewProduct.java.

Now create the composite key class. This follows the same pattern you used for EnrollmentId in Scenario 1.

  • Create ProductReviewCompositeId.java in the scenario4 package with the following code:
package com.example.keystrategy.scenario4;

import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import java.io.Serializable;
import java.util.Objects;

@Embeddable
public class ProductReviewCompositeId implements Serializable {

    @Column(name = "user_id")
    private Long userId;

    @Column(name = "product_id")
    private Long productId;

    public ProductReviewCompositeId() {}

    public ProductReviewCompositeId(Long userId, Long productId) {
        this.userId = userId;
        this.productId = productId;
    }

    public Long getUserId() { return userId; }
    public void setUserId(Long userId) { this.userId = userId; }
    public Long getProductId() { return productId; }
    public void setProductId(Long productId) { this.productId = productId; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof ProductReviewCompositeId that)) return false;
        return Objects.equals(userId, that.userId) &&
               Objects.equals(productId, that.productId);
    }

    @Override
    public int hashCode() {
        return Objects.hash(userId, productId);
    }
}

What does this code do?

This embeddable key class encapsulates the (user_id, product_id) pair as the composite identity. It implements Serializable and overrides equals() and hashCode() so Hibernate can compare key instances correctly.

The @Column annotations define the physical column names that will appear in the generated table.

  • Save ProductReviewCompositeId.java.

Now create the first review entity that uses this composite key.

  • Create ProductReviewComposite.java in the scenario4 package with the following code:
package com.example.keystrategy.scenario4;

import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.MapsId;
import jakarta.persistence.Table;

@Entity
@Table(name = "s4_review_composite")
public class ProductReviewComposite {

    // Composite key: identity IS the user+product combination
    @EmbeddedId
    private ProductReviewCompositeId id;

    // Map the user relationship to the userId field in the composite key
    @ManyToOne
    @MapsId("userId")
    @JoinColumn(name = "user_id")
    private ReviewUser user;

    // Map the product relationship to the productId field in the composite key
    @ManyToOne
    @MapsId("productId")
    @JoinColumn(name = "product_id")
    private ReviewProduct product;

    private int rating;

    private String comment;

    public ProductReviewComposite() {}

    public ProductReviewComposite(ReviewUser user, ReviewProduct product) {
        this.id = new ProductReviewCompositeId(user.getId(), product.getId());
        this.user = user;
        this.product = product;
    }

    public ProductReviewCompositeId getId() { return id; }
    public void setId(ProductReviewCompositeId id) { this.id = id; }
    public ReviewUser getUser() { return user; }
    public void setUser(ReviewUser user) { this.user = user; }
    public ReviewProduct getProduct() { return product; }
    public void setProduct(ReviewProduct product) { this.product = product; }
    public int getRating() { return rating; }
    public void setRating(int rating) { this.rating = rating; }
    public String getComment() { return comment; }
    public void setComment(String comment) { this.comment = comment; }
}

What does this code do?

This is the composite key version. The entity's identity is the (user_id, product_id) pair. @EmbeddedId uses the composite key class, and each @MapsId connects a relationship field back to its corresponding key attribute.

Uniqueness is automatic here. The primary key itself guarantees one review per user per product. No separate @UniqueConstraint is needed.

  • Save ProductReviewComposite.java.

Finally, create the surrogate key version of the same entity. This maps to a different table name so Hibernate generates DDL for both simultaneously.

  • Create ProductReviewSurrogate.java in the scenario4 package with the following code:
package com.example.keystrategy.scenario4;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;

@Entity
@Table(name = "s4_review_surrogate", uniqueConstraints = {
    @UniqueConstraint(columnNames = {"user_id", "product_id"})
})
public class ProductReviewSurrogate {

    // Surrogate key: identity is a system-generated id
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "user_id")
    private ReviewUser user;

    @ManyToOne
    @JoinColumn(name = "product_id")
    private ReviewProduct product;

    private int rating;

    private String comment;

    public ProductReviewSurrogate() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public ReviewUser getUser() { return user; }
    public void setUser(ReviewUser user) { this.user = user; }
    public ReviewProduct getProduct() { return product; }
    public void setProduct(ReviewProduct product) { this.product = product; }
    public int getRating() { return rating; }
    public void setRating(int rating) { this.rating = rating; }
    public String getComment() { return comment; }
    public void setComment(String comment) { this.comment = comment; }
}

What does this code do?

This is the surrogate key version. The entity gets a system-generated id column as its primary key. The business rule (one review per user per product) is enforced by the @UniqueConstraint on the @Table annotation.

Notice that the relationships use plain @ManyToOne with @JoinColumn. No @MapsId is needed because the FK columns are regular columns, not part of the primary key.

  • Save ProductReviewSurrogate.java.

Seeing compile errors?

Make sure all five files are in the same com.example.keystrategy.scenario4 package. The import references between ProductReviewComposite and ProductReviewCompositeId resolve within the same package, so no explicit import is needed.

Help me fix compile errors in my scenario4 entity classes.

✔️ Awesome, I've got everything!

Make sure all five files are saved. You are about to run the application to generate DDL for both tables.

ⓧ I'd like to double check the full code

Here are all five Scenario 4 files for comparison.

package com.example.keystrategy.scenario4;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "s4_user")
public class ReviewUser {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String username;

    public ReviewUser() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }
}
package com.example.keystrategy.scenario4;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "s4_product")
public class ReviewProduct {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    public ReviewProduct() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
}
package com.example.keystrategy.scenario4;

import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import java.io.Serializable;
import java.util.Objects;

@Embeddable
public class ProductReviewCompositeId implements Serializable {

    @Column(name = "user_id")
    private Long userId;

    @Column(name = "product_id")
    private Long productId;

    public ProductReviewCompositeId() {}

    public ProductReviewCompositeId(Long userId, Long productId) {
        this.userId = userId;
        this.productId = productId;
    }

    public Long getUserId() { return userId; }
    public void setUserId(Long userId) { this.userId = userId; }
    public Long getProductId() { return productId; }
    public void setProductId(Long productId) { this.productId = productId; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof ProductReviewCompositeId that)) return false;
        return Objects.equals(userId, that.userId) &&
               Objects.equals(productId, that.productId);
    }

    @Override
    public int hashCode() {
        return Objects.hash(userId, productId);
    }
}
package com.example.keystrategy.scenario4;

import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.MapsId;
import jakarta.persistence.Table;

@Entity
@Table(name = "s4_review_composite")
public class ProductReviewComposite {

    @EmbeddedId
    private ProductReviewCompositeId id;

    @ManyToOne
    @MapsId("userId")
    @JoinColumn(name = "user_id")
    private ReviewUser user;

    @ManyToOne
    @MapsId("productId")
    @JoinColumn(name = "product_id")
    private ReviewProduct product;

    private int rating;

    private String comment;

    public ProductReviewComposite() {}

    public ProductReviewComposite(ReviewUser user, ReviewProduct product) {
        this.id = new ProductReviewCompositeId(user.getId(), product.getId());
        this.user = user;
        this.product = product;
    }

    public ProductReviewCompositeId getId() { return id; }
    public void setId(ProductReviewCompositeId id) { this.id = id; }
    public ReviewUser getUser() { return user; }
    public void setUser(ReviewUser user) { this.user = user; }
    public ReviewProduct getProduct() { return product; }
    public void setProduct(ReviewProduct product) { this.product = product; }
    public int getRating() { return rating; }
    public void setRating(int rating) { this.rating = rating; }
    public String getComment() { return comment; }
    public void setComment(String comment) { this.comment = comment; }
}
package com.example.keystrategy.scenario4;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;

@Entity
@Table(name = "s4_review_surrogate", uniqueConstraints = {
    @UniqueConstraint(columnNames = {"user_id", "product_id"})
})
public class ProductReviewSurrogate {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "user_id")
    private ReviewUser user;

    @ManyToOne
    @JoinColumn(name = "product_id")
    private ReviewProduct product;

    private int rating;

    private String comment;

    public ProductReviewSurrogate() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public ReviewUser getUser() { return user; }
    public void setUser(ReviewUser user) { this.user = user; }
    public ReviewProduct getProduct() { return product; }
    public void setProduct(ReviewProduct product) { this.product = product; }
    public int getRating() { return rating; }
    public void setRating(int rating) { this.rating = rating; }
    public String getComment() { return comment; }
    public void setComment(String comment) { this.comment = comment; }
}
Run and compare DDL side by side

Both entity versions map to different table names, so Hibernate generates DDL for both in a single application run. This lets you see the physical schema differences directly in the console output.

  • Run the application by clicking the green play button in IntelliJ IDEA or pressing Shift+F10 (Windows) or Ctrl+R (macOS).
  • In the console output, scroll up and look for the CREATE TABLE statements containing s4_review_composite and s4_review_surrogate.

You should see two distinct table structures. Compare them and note these concrete differences:

  • The s4_review_composite table has PRIMARY KEY (user_id, product_id) with no id column and no separate unique constraint needed.
  • The s4_review_surrogate table has PRIMARY KEY (id), an extra auto-generated id column, and a separate UNIQUE constraint on (user_id, product_id).

What are the physical differences?

The composite version has fewer columns (no id) and fewer indexes (the PK doubles as the uniqueness enforcement). The surrogate version has one extra column and one extra index (the unique constraint) on top of the PK index.

For a small table this overhead is negligible. For a table with hundreds of millions of rows, the extra index and column add measurable storage and write cost.

Not seeing the s4 tables?

Confirm that spring.jpa.hibernate.ddl-auto=create is set in application.properties. This forces Hibernate to drop and recreate all tables on every startup.

Also check that your entity files have the correct @Entity and @Table annotations. Without @Entity, Hibernate will not scan the class.

Help me figure out why Hibernate is not generating my s4 tables.

Write your argument and decision-tree checklist

You have now seen both approaches produce valid schemas for the same business requirement. The final task is to articulate your own reasoned choice and consolidate everything you have learned across all four scenarios into a reusable checklist.

  • Create a file called README.md at the root of your key-strategy project folder.

In this file, first write your Scenario 4 argument. Address these four questions:

  • Will any other entity ever reference this row?
  • Is the business uniqueness rule likely to change?
  • What is the storage/index overhead tradeoff?
  • Which approach produces simpler queries for the access patterns you anticipate?

There is no single correct answer here

The point of Scenario 4 is that you can articulate the tradeoff. Both approaches work. What matters is that you can explain WHY you chose one over the other, grounded in concrete schema consequences you just observed.

Below your argument, add the decision-tree checklist that consolidates everything from all four scenarios. This is your reusable mental model for any future entity design.

  • Copy the following decision-tree checklist into your README.md below your Scenario 4 argument:
# Key Strategy Decision Tree

## My Scenario 4 Argument

(Write your choice and reasoning here, addressing all four questions above.)

## Decision Tree Checklist

1. Will any other entity ever need a foreign key pointing to this specific row?
   - YES: Use a surrogate key. Composite FKs are fragile and wide.
   - NO: Continue to question 2.

2. Is the combination of columns that defines uniqueness likely to change in the future?
   - YES: Use a surrogate key + unique constraint. Business rules change; PKs should not.
   - NO: Continue to question 3.

3. Does the row have ANY identity beyond the combination of its foreign keys?
   - YES: Use a surrogate key. The row is its own thing, not just a relationship.
   - NO: Continue to question 4.

4. Is the composite key the ONLY columns in the table (pure junction table with no extra data)?
   - YES: Composite key is the natural choice. Minimal overhead, maximum clarity.
   - NO: Either approach works. Consider query patterns and team conventions.

What does this checklist capture?

Each question maps directly to a scenario you implemented. Question 1 is Scenario 2 (Refund referencing OrderItem). Question 2 is Scenario 3 (Attendance rule changing). Questions 3 and 4 distinguish Scenario 1 (pure join entity) from Scenario 4 (ambiguous case with extra data).

The key insight: migrating FROM a composite key to a surrogate key is painful (PK migration plus FK rewrites everywhere). Migrating from a surrogate key to a composite key is something you would never need to do, because the surrogate key with a unique constraint already gives you everything the composite key gives you, plus flexibility.

  • Fill in the "My Scenario 4 Argument" section with your own reasoning.
  • Save README.md.

Secret mission

Apply Your Decision Tree to an Unseen Scenario

You have a decision tree. Now prove you can use it. A brand-new domain drops in your lap with zero guidance on key strategy. Apply your checklist, implement the entities, and defend your choice with explicit references to the decision-tree questions that guided you.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally with a Docker MySQL container, so there are no ongoing cloud costs. Decide whether to keep your resources for reference, pause them to free up system resources, or delete them entirely.

Resources you used:

  • The key-strategy project folder on your Desktop.
  • The keystrategy MySQL database inside your Docker container.

Keep everything running

No action needed. Choose this if you want to revisit the entity scenarios, experiment with new key strategies, or reference the decision-tree checklist later.

  • Your key-strategy folder stays on your Desktop.
  • The keystrategy database stays in your Docker MySQL container.
  • The Docker MySQL container continues using memory while running.

Pause - I'll come back to this later

Stop your Docker MySQL container to free up memory. Your database and project files remain intact for when you return.

  • Stop the Docker MySQL container by running:
docker stop [[MYSQL_CONTAINER="your-mysql-container-name"]]
  • When you want to resume, start the container again by running:
docker start [[MYSQL_CONTAINER="your-mysql-container-name"]]
  • Your key-strategy folder and keystrategy database will be exactly as you left them.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes your entity code, decision-tree notes, and the database.

  • Drop the keystrategy database by running:
docker exec -it [[MYSQL_CONTAINER="your-mysql-container-name"]] mysql -uroot -p -e "DROP DATABASE keystrategy;"
  • Enter your MySQL root password when prompted.
  • Delete the project folder from your Desktop.

macOS

rm -rf ~/Desktop/key-strategy

Windows

rmdir /s /q %USERPROFILE%\Desktop\key-strategy
  • Confirm the folder is gone by checking your Desktop.

Nice Work!

Nice Work!

Nice work! You have just built a complete JPA key strategy toolkit, backed by 4 real-world scenarios and verified by real Hibernate-generated DDL.

You learned how to:

  • Implement composite keys with @EmbeddedId and @MapsId for pure join entities, and surrogate keys with @GeneratedValue and @UniqueConstraint when referenceability or flexibility demands it.
  • Compare real Hibernate-generated DDL side by side to see how composite vs. surrogate key choices produce different physical schemas.
  • Create a personal decision-tree checklist for choosing between composite and surrogate keys on any future entity design.
  • Secret Mission: Applied your decision tree to a brand-new warehouse inventory scenario with multiple competing factors and defended your choice.

Ready to quiz yourself?