Spring Boot
Composite Keys
A composite key is a primary key made up of two or more columns. JPA supports composite keys through two approaches: @IdClass, which keeps the key fields directly on the entity, and @EmbeddedId, which wraps them in a separate embeddable class. Both require a dedicated key class that implements Serializable and correctly overrides equals() and hashCode(). Composite keys are common in legacy schemas, join tables, and multi-tenant data models.
When Composite Keys Arise
Composite keys appear in three main scenarios. Legacy schemas often use natural composite keys — (order_id, product_id) in an order_items table, or (country_code, postal_code) in an address table. Join tables representing many-to-many relationships use both foreign keys as the primary key. Multi-tenant schemas use (tenant_id, entity_id) to scope every row to a tenant.
JPA requires that the primary key class satisfy three rules: it must implement java.io.Serializable, it must override equals() and hashCode() based on all key fields, and it must have a public no-arg constructor. Violating any of these produces runtime errors that are often difficult to diagnose.
JPA offers two annotations for composite keys: @IdClass (key fields declared twice — once on the entity, once in the key class) and @EmbeddedId (key fields declared once in an embeddable class, referenced from the entity via a single field). @EmbeddedId is generally preferred for new code because the key is a first-class object with its own type.
@IdClass — Fields on the Entity
@IdClass keeps the key columns as individual @Id fields on the entity. A separate key class mirrors those fields. Spring Data JPA uses the key class as the ID type for the repository.
Java
// ── 1. The key class — mirrors the @Id fields on the entity: ──────────
public class OrderItemId implements Serializable {
private Long orderId;
private Long productId;
// Required: public no-arg constructor
public OrderItemId() { }
public OrderItemId(Long orderId, Long productId) {
this.orderId = orderId;
this.productId = productId;
}
// Required: equals and hashCode based on ALL key fields
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof OrderItemId)) return false;
OrderItemId that = (OrderItemId) o;
return Objects.equals(orderId, that.orderId)
&& Objects.equals(productId, that.productId);
}
@Override
public int hashCode() {
return Objects.hash(orderId, productId);
}
// Getters (setters optional — key is typically immutable):
public Long getOrderId() { return orderId; }
public Long getProductId() { return productId; }
}
// ── 2. The entity — @IdClass references the key class: ────────────────
@Entity
@Table(name = "order_items")
@IdClass(OrderItemId.class)
public class OrderItem {
// Both @Id fields match the fields in OrderItemId:
@Id
@Column(name = "order_id")
private Long orderId;
@Id
@Column(name = "product_id")
private Long productId;
// Non-key columns:
@Column(nullable = false)
private int quantity;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal unitPrice;
// Relationships using the FK columns:
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id", insertable = false, updatable = false)
private Order order;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id", insertable = false, updatable = false)
private Product product;
protected OrderItem() { }
public OrderItem(Long orderId, Long productId, int quantity, BigDecimal unitPrice) {
this.orderId = orderId;
this.productId = productId;
this.quantity = quantity;
this.unitPrice = unitPrice;
}
// Getters + setters:
public Long getOrderId() { return orderId; }
public Long getProductId() { return productId; }
public int getQuantity() { return quantity; }
public void setQuantity(int quantity) { this.quantity = quantity; }
public BigDecimal getUnitPrice() { return unitPrice; }
public void setUnitPrice(BigDecimal unitPrice) { this.unitPrice = unitPrice; }
}
// ── 3. Repository — ID type is the key class: ─────────────────────────
@Repository
public interface OrderItemRepository
extends JpaRepository<OrderItem, OrderItemId> {
List<OrderItem> findByOrderId(Long orderId);
List<OrderItem> findByProductId(Long productId);
boolean existsByOrderIdAndProductId(Long orderId, Long productId);
}
// ── 4. Usage: ─────────────────────────────────────────────────────────
// Save:
OrderItem item = new OrderItem(1L, 42L, 3, new BigDecimal("9.99"));
orderItemRepository.save(item);
// Find by composite key:
OrderItemId key = new OrderItemId(1L, 42L);
Optional<OrderItem> found = orderItemRepository.findById(key);
// Delete by composite key:
orderItemRepository.deleteById(new OrderItemId(1L, 42L));@EmbeddedId — Key as an Embeddable
@EmbeddedId wraps all key fields in a single @Embeddable class. The entity holds one field of that type annotated with @EmbeddedId. The key object is a first-class value — it can be constructed, compared, and passed around independently.
Java
// ── 1. The embeddable key class: ──────────────────────────────────────
@Embeddable
public class CourseEnrollmentId implements Serializable {
@Column(name = "student_id")
private Long studentId;
@Column(name = "course_id")
private Long courseId;
public CourseEnrollmentId() { }
public CourseEnrollmentId(Long studentId, Long courseId) {
this.studentId = studentId;
this.courseId = courseId;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof CourseEnrollmentId)) return false;
CourseEnrollmentId that = (CourseEnrollmentId) o;
return Objects.equals(studentId, that.studentId)
&& Objects.equals(courseId, that.courseId);
}
@Override
public int hashCode() {
return Objects.hash(studentId, courseId);
}
public Long getStudentId() { return studentId; }
public Long getCourseId() { return courseId; }
}
// ── 2. The entity — single @EmbeddedId field: ─────────────────────────
@Entity
@Table(name = "course_enrollments")
public class CourseEnrollment {
@EmbeddedId
private CourseEnrollmentId id;
@Column(nullable = false)
private LocalDate enrolledAt;
@Enumerated(EnumType.STRING)
private EnrollmentStatus status = EnrollmentStatus.ACTIVE;
// Relationships — use @MapsId to avoid duplicate column mappings:
@ManyToOne(fetch = FetchType.LAZY)
@MapsId("studentId") // maps to id.studentId
@JoinColumn(name = "student_id")
private Student student;
@ManyToOne(fetch = FetchType.LAZY)
@MapsId("courseId") // maps to id.courseId
@JoinColumn(name = "course_id")
private Course course;
protected CourseEnrollment() { }
public CourseEnrollment(Student student, Course course) {
this.id = new CourseEnrollmentId(student.getId(), course.getId());
this.student = student;
this.course = course;
this.enrolledAt = LocalDate.now();
}
public CourseEnrollmentId getId() { return id; }
public Student getStudent() { return student; }
public Course getCourse() { return course; }
public LocalDate getEnrolledAt() { return enrolledAt; }
public EnrollmentStatus getStatus() { return status; }
public void setStatus(EnrollmentStatus status) { this.status = status; }
public enum EnrollmentStatus { ACTIVE, COMPLETED, WITHDRAWN }
}
// ── 3. Repository: ────────────────────────────────────────────────────
@Repository
public interface CourseEnrollmentRepository
extends JpaRepository<CourseEnrollment, CourseEnrollmentId> {
List<CourseEnrollment> findByIdStudentId(Long studentId);
List<CourseEnrollment> findByIdCourseId(Long courseId);
@Query("SELECT e FROM CourseEnrollment e " +
"JOIN FETCH e.student JOIN FETCH e.course " +
"WHERE e.id.studentId = :studentId")
List<CourseEnrollment> findByStudentIdWithDetails(@Param("studentId") Long studentId);
boolean existsByIdStudentIdAndIdCourseId(Long studentId, Long courseId);
}
// ── 4. Usage: ─────────────────────────────────────────────────────────
// Save (using @MapsId approach — pass the entities, not just IDs):
CourseEnrollment enrollment = new CourseEnrollment(student, course);
enrollmentRepository.save(enrollment);
// Find by composite key:
CourseEnrollmentId key = new CourseEnrollmentId(studentId, courseId);
Optional<CourseEnrollment> found = enrollmentRepository.findById(key);
// Derived query using nested key field:
List<CourseEnrollment> byStudent =
enrollmentRepository.findByIdStudentId(studentId);@IdClass vs @EmbeddedId — Comparison
Both approaches produce identical database schemas. The difference is purely in the Java model. Choose based on whether the composite key has domain meaning as its own type, and on your team's preference.
Java
// ── @IdClass ──────────────────────────────────────────────────────────
// Pros:
// - Key fields live directly on the entity — less indirection
// - Derived queries reference fields directly: findByOrderId(Long orderId)
// - Slightly simpler for simple join-table entities
// Cons:
// - Key fields declared twice (entity + key class) — duplication
// - Key class is a parallel structure, not embedded in the entity graph
// - Less object-oriented — the key is not a first-class domain object
// ── @EmbeddedId ───────────────────────────────────────────────────────
// Pros:
// - Key declared once — no duplication
// - Key is a typed value object — can be constructed and passed around
// - Cleaner with @MapsId — no insertable/updatable = false workarounds
// Cons:
// - Derived queries must navigate through id: findByIdOrderId(Long orderId)
// - One extra level of nesting in JPQL: WHERE e.id.orderId = :orderId
// ── JPQL comparison: ──────────────────────────────────────────────────
// @IdClass:
@Query("SELECT o FROM OrderItem o WHERE o.orderId = :orderId")
List<OrderItem> findByOrder(@Param("orderId") Long orderId);
// @EmbeddedId:
@Query("SELECT e FROM CourseEnrollment e WHERE e.id.studentId = :studentId")
List<CourseEnrollment> findByStudent(@Param("studentId") Long studentId);
// ── Derived query comparison: ─────────────────────────────────────────
// @IdClass — direct field access:
List<OrderItem> findByOrderId(Long orderId);
// @EmbeddedId — navigate through id:
List<CourseEnrollment> findByIdStudentId(Long studentId);
// ── General guidance: ─────────────────────────────────────────────────
// Use @EmbeddedId when the key has domain meaning (CourseEnrollmentId)
// or when it will be reused across multiple entities.
// Use @IdClass for simple join tables where the key has no domain meaning
// and you want the cleanest possible derived query method names.Multi-Tenant Composite Keys
Multi-tenant schemas scope every row to a tenant using a (tenant_id, id) composite key. This pattern ensures complete data isolation at the database level and is a common use case for both @IdClass and @EmbeddedId.
Java
// ── Embeddable key with tenant scope: ────────────────────────────────
@Embeddable
public class TenantScopedId implements Serializable {
@Column(name = "tenant_id", nullable = false)
private String tenantId;
@Column(name = "id", nullable = false)
private Long id;
public TenantScopedId() { }
public TenantScopedId(String tenantId, Long id) {
this.tenantId = tenantId;
this.id = id;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof TenantScopedId)) return false;
TenantScopedId that = (TenantScopedId) o;
return Objects.equals(tenantId, that.tenantId)
&& Objects.equals(id, that.id);
}
@Override
public int hashCode() { return Objects.hash(tenantId, id); }
public String getTenantId() { return tenantId; }
public Long getId() { return id; }
}
// ── Tenant-scoped entity: ─────────────────────────────────────────────
@Entity
@Table(name = "tenant_products")
public class TenantProduct {
@EmbeddedId
private TenantScopedId id;
@Column(nullable = false)
private String name;
private BigDecimal price;
protected TenantProduct() { }
public TenantProduct(String tenantId, Long productId, String name) {
this.id = new TenantScopedId(tenantId, productId);
this.name = name;
}
public TenantScopedId getId() { return id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
}
// ── Repository with tenant-scoped queries: ────────────────────────────
@Repository
public interface TenantProductRepository
extends JpaRepository<TenantProduct, TenantScopedId> {
// All products for a tenant:
List<TenantProduct> findByIdTenantId(String tenantId);
// Specific product within a tenant:
Optional<TenantProduct> findByIdTenantIdAndIdId(String tenantId, Long id);
// Delete all products for a tenant:
@Modifying
@Transactional
void deleteByIdTenantId(String tenantId);
@Query("SELECT p FROM TenantProduct p WHERE p.id.tenantId = :tenantId " +
"AND LOWER(p.name) LIKE LOWER(CONCAT('%', :search, '%'))")
List<TenantProduct> searchByName(@Param("tenantId") String tenantId,
@Param("search") String search);
}
// ── Usage: ────────────────────────────────────────────────────────────
// Save:
TenantProduct product = new TenantProduct("tenant-abc", 1L, "Widget Pro");
repository.save(product);
// Find:
TenantScopedId key = new TenantScopedId("tenant-abc", 1L);
Optional<TenantProduct> found = repository.findById(key);
// All for tenant:
List<TenantProduct> all = repository.findByIdTenantId("tenant-abc");Lombok with Composite Keys
Lombok reduces boilerplate on key classes but requires careful annotation choices. @Data generates equals() and hashCode() but based on all fields — correct for key classes. @EqualsAndHashCode must explicitly include all key fields. @Builder works well for key construction.
Java
// ── @IdClass with Lombok: ─────────────────────────────────────────────
@Data // generates equals, hashCode, toString, getters, setters
@NoArgsConstructor // required no-arg constructor
@AllArgsConstructor // convenience constructor
public class OrderItemId implements Serializable {
private Long orderId;
private Long productId;
// @Data generates equals/hashCode based on orderId + productId — correct
}
// ── @Embeddable with Lombok: ───────────────────────────────────────────
@Embeddable
@Getter
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode // generates equals/hashCode — required for JPA
public class CourseEnrollmentId implements Serializable {
@Column(name = "student_id")
private Long studentId;
@Column(name = "course_id")
private Long courseId;
}
// ── Entity with Lombok: ───────────────────────────────────────────────
@Entity
@Table(name = "course_enrollments")
@Getter
@Setter
@NoArgsConstructor
public class CourseEnrollment {
@EmbeddedId
private CourseEnrollmentId id;
@ManyToOne(fetch = FetchType.LAZY)
@MapsId("studentId")
@JoinColumn(name = "student_id")
private Student student;
@ManyToOne(fetch = FetchType.LAZY)
@MapsId("courseId")
@JoinColumn(name = "course_id")
private Course course;
private LocalDate enrolledAt;
public CourseEnrollment(Student student, Course course) {
this.id = new CourseEnrollmentId(student.getId(), course.getId());
this.student = student;
this.course = course;
this.enrolledAt = LocalDate.now();
}
}
// ── WARNING — @EqualsAndHashCode and lazy associations: ───────────────
// Never include lazy-loaded associations in equals/hashCode.
// Accessing them outside a transaction causes LazyInitializationException.
// Key classes should only include the actual key fields — never relationships.