Mapping concatenated primary keys

JPA and Ebean use @Embeddable to map concatenated primary keys (via @IdClass or @EmbeddedId). Primary keys are generally unchanging and best represented by an immutable class.

In the example below, the primary key is made up of partId and brandId.

@Embeddable
public final class PartBrandKey {

  private final long partId;
  private final long brandId;

  public PartBrandKey(long partId, long brandId) {
    this.partId = partId;
    this.brandId = brandId;
  }

  public long partId() {
    return partId;
  }

  public long brandId() {
    return brandId;
  }

  @Override
  public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    PartBrandKey that = (PartBrandKey) o;
    return partId == that.partId && brandId == that.brandId;
  }

  @Override
  public int hashCode() {
    return Objects.hash(partId, brandId);
  }
}

There is a requirement to correctly implement equals() and hashCode() for an @Embeddable that maps a primary key. The implementations must include all components (partId and brandId in this case). The ORM cannot fix your mistakes here, but most IDEs could generate these methods for you.

Adding a default constructor

Some JPA implementations require a default constructor. This forces Java classes to look much less immutable:

  • The fields are no longer final
  • We have a private constructor that doesn't look like its used - it will be used by the ORM
  • The class is now 'effectively immutable' rather than strictly immutable (internally the ORM is going to use that private constructor, set the values, then hand off the fully populated effective immutable instance)
@Embeddable
public final class PartBrandKey {

  private long partId;         /** no longer final */
  private long brandId;        /** no longer final */

  private PartBrandKey() { }   /** for ORM use only */

  public PartBrandKey(long partId, long brandId) {
    this.partId = partId;
    this.brandId = brandId;
  }

  public long partId() {
    return partId;
  }

  public long brandId() {
    return brandId;
  }

  @Override
  public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    PartBrandKey that = (PartBrandKey) o;
    return partId == that.partId && brandId == that.brandId;
  }

  @Override
  public int hashCode() {
    return Objects.hash(partId, brandId);
  }
}

Java 14 record type

Java 14 records are immutable with correct equals() and hashCode() implementations. They provide a perfect match for the semantics to map a concatenated primary key with @Embeddable.

@Embeddable
public record PartBrandKey(long partId, long brandId) { }

Kotlin data class

Kotlin data class with val parameters also perfectly match the semantics.

@Embeddable
data class PartBrandKey(val partId: Long, val brandId: Long)

Java Language vs JVM Bytecode

Annotations like @Embeddable or @Transactional are markers for transformation to be applied to the bytecode.

Ebean bytecode transformation applies equally to Java and other JVM languages like Kotlin, Scala, and Groovy. Java 14 record types are a very important (and nice) step forward for the Java language, but the bytecode they compile to isn't much different from immutable Java non-record classes or Kotlin data classes. No changes were necessary to the Ebean code in order to support Java 14 records.

Java Memory Model

In the related SO post we mention "Java Memory Model" which is in reference to the JVM specification and JSR-133.

This relates to the point that although we no longer have final fields and hence don't get the memory visibility guarantees that provides. Noting the instances in this case are not accessible by multiple threads until they are 'effectively immutable'.