DEV Community

Allan Roberto
Allan Roberto

Posted on

Composite Keys in Jakarta Persistence

How to use @Embeddable, @EmbeddedId, and @MapsId

When modeling relational databases with Jakarta Persistence (JPA), there are situations where a single column cannot uniquely identify a record. In these cases, a composite primary key is required.

Jakarta Persistence provides a clean and expressive way to model composite keys using:

  • @Embeddable
  • @EmbeddedId
  • @MapsId

These annotations allow developers to build more explicit domain models while keeping entity relationships consistent with the database design.

This article explores how to implement composite keys and how to map relationships when entities share parts of the primary key.


When do you need a composite key?

A composite key is useful when the identity of a row depends on multiple columns.

Consider the following example: an order_items table.

order_id product_id quantity price
1 10 2 15.00
1 15 1 30.00

Neither order_id nor product_id alone is sufficient to uniquely identify a row.
But together they form a unique identifier.

In Jakarta Persistence, this scenario is typically modeled using an embedded primary key class.


Creating a composite key with @Embeddable

The first step is defining a class that represents the composite identifier.

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

@Embeddable
public class OrderItemId implements Serializable {

    @Column(name = "order_id")
    private Long orderId;

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

    public OrderItemId() {}

    public OrderItemId(Long orderId, Long productId) {
        this.orderId = orderId;
        this.productId = productId;
    }

    public Long getOrderId() {
        return orderId;
    }

    public Long getProductId() {
        return productId;
    }

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

    @Override
    public int hashCode() {
        return Objects.hash(orderId, productId);
    }
}
Enter fullscreen mode Exit fullscreen mode

Important requirements

A composite key class must:

  • Implement Serializable
  • Have a no-argument constructor
  • Implement equals() and hashCode()

These requirements ensure that JPA can correctly manage entity identity and caching.


Using the composite key with @EmbeddedId

Once the key class is created, it can be embedded into the entity using @EmbeddedId.

import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.math.BigDecimal;

@Entity
@Table(name = "order_items")
public class OrderItem {

    @EmbeddedId
    private OrderItemId id;

    private Integer quantity;

    private BigDecimal price;

    public OrderItem() {}

    public OrderItem(OrderItemId id, Integer quantity, BigDecimal price) {
        this.id = id;
        this.quantity = quantity;
        this.price = price;
    }

    public OrderItemId getId() {
        return id;
    }
}
Enter fullscreen mode Exit fullscreen mode

At this point, the entity works correctly with a composite key. However, there is still an important improvement we can make when relationships are involved.


The limitation without @MapsId

In real-world scenarios, OrderItem would likely reference Order and Product entities.

A naive implementation might look like this:

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

@ManyToOne
@JoinColumn(name = "product_id")
private Product product;
Enter fullscreen mode Exit fullscreen mode

The problem is that the order_id and product_id columns would now appear twice:

  • once inside OrderItemId
  • once in the relationship mapping

This duplication can lead to confusion and maintenance problems.

This is exactly where @MapsId becomes useful.


Using @MapsId to map relationships

@MapsId tells JPA that the identifier of a relationship should be used as part of the entity's primary key.

Updated entity

import jakarta.persistence.*;

@Entity
@Table(name = "order_items")
public class OrderItem {

    @EmbeddedId
    private OrderItemId id;

    @ManyToOne
    @MapsId("orderId")
    @JoinColumn(name = "order_id")
    private Order order;

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

    private Integer quantity;

    private java.math.BigDecimal price;

    public OrderItem() {}

    public OrderItem(Order order, Product product, Integer quantity) {
        this.order = order;
        this.product = product;
        this.id = new OrderItemId(order.getId(), product.getId());
        this.quantity = quantity;
    }
}
Enter fullscreen mode Exit fullscreen mode

What @MapsId does

@MapsId("orderId") tells JPA:

The order_id column used in this relationship corresponds to the orderId field inside the composite key.

The same applies for:

@MapsId("productId")
Enter fullscreen mode Exit fullscreen mode

As a result:

  • The composite key stays consistent
  • The relationship mapping becomes cleaner
  • Duplicate column mappings are avoided

Persisting entities with @MapsId

Persisting an entity now looks like this:

Order order = entityManager.find(Order.class, 1L);
Product product = entityManager.find(Product.class, 10L);

OrderItem item = new OrderItem(order, product, 2);

entityManager.persist(item);
Enter fullscreen mode Exit fullscreen mode

The ID is automatically aligned with the relationships because of @MapsId.


Using @Embedded for value objects

While @EmbeddedId is used for primary keys, @Embedded is used for value objects inside entities.

For example, an address can be embedded in a Customer.

Value object

@Embeddable
public class Address {

    private String street;
    private String city;
    private String zipCode;

}
Enter fullscreen mode Exit fullscreen mode

Entity

@Entity
public class Customer {

    @Id
    private Long id;

    private String name;

    @Embedded
    private Address address;

}
Enter fullscreen mode Exit fullscreen mode

This results in a single table containing:

id
name
street
city
zip_code
Enter fullscreen mode Exit fullscreen mode

There is no separate address table.

This pattern works very well for value objects in domain-driven design.


Best practices when working with composite keys

1. Prefer @EmbeddedId over @IdClass

Both approaches exist, but @EmbeddedId is generally:

  • cleaner
  • easier to maintain
  • more aligned with domain modeling

2. Use @MapsId for relationship-based keys

Whenever a composite key references other entities, @MapsId keeps the model consistent and avoids column duplication.


3. Keep key classes simple

Composite key classes should contain only identification fields, not business logic.


4. Implement equality carefully

Incorrect equals() and hashCode() implementations are one of the most common causes of subtle JPA bugs.


Final thoughts

Composite keys are common in many database designs, especially in:

  • join tables
  • many-to-many relationships with attributes
  • domain models where identity depends on multiple fields

Jakarta Persistence provides powerful tools to model these scenarios cleanly:

  • @Embeddable defines reusable identifier classes
  • @EmbeddedId embeds those identifiers as entity keys
  • @MapsId links relationships directly to the composite key

When used correctly, these annotations help create clear, maintainable, and expressive entity models that align well with the underlying database schema.

Understanding how they work together is an important step toward mastering Jakarta Persistence.

Top comments (0)