Entity Relationships: @OneToMany, @ManyToOne, @ManyToMany
Relationships are the trickiest part of JPA. A wrong cascade type or a missing mappedBy causes subtle bugs that appear in production. This article covers every relationship type with real examples and the pitfalls to avoid.
Relationship Fundamentals
JPA relationships can be:
- Direction: Unidirectional (one side knows about the other) or Bidirectional (both sides know each other)
- Cardinality:
@OneToOne,@OneToMany,@ManyToOne,@ManyToMany - Fetch:
LAZY(load on access) orEAGER(load immediately) - Ownership: The side with the foreign key column is the owner
Default fetch types:
@ManyToOne→ EAGER (problematic — change to LAZY)@OneToOne→ EAGER (change to LAZY)@OneToMany→ LAZY (good)@ManyToMany→ LAZY (good)
Always make @ManyToOne and @OneToOne LAZY explicitly.
@ManyToOne — The Owning Side
The most common relationship. Each OrderItem belongs to one Order:
@Entity
@Table(name = "order_items")
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
// Many items → One order
// This side owns the relationship (has the FK column: order_id)
@ManyToOne(fetch = FetchType.LAZY) // always LAZY
@JoinColumn(name = "order_id", nullable = false)
private Order order;
@Column(name = "product_id", nullable = false)
private UUID productId;
@Column(name = "product_name", nullable = false)
private String productName;
@Positive
private int quantity;
@Column(name = "unit_price", precision = 10, scale = 2)
private BigDecimal unitPrice;
}
@OneToMany — The Inverse Side
The Order side of the relationship:
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
// One order → many items
// mappedBy = the field name in OrderItem that owns the relationship
@OneToMany(
mappedBy = "order",
cascade = CascadeType.ALL, // save/delete items when saving/deleting order
orphanRemoval = true, // delete items removed from the list
fetch = FetchType.LAZY // don't load items unless accessed
)
private List<OrderItem> items = new ArrayList<>();
// Always manage both sides of a bidirectional relationship
public void addItem(OrderItem item) {
items.add(item);
item.setOrder(this); // set the owning side
}
public void removeItem(OrderItem item) {
items.remove(item);
item.setOrder(null); // clear the owning side
}
}
Critical rules for bidirectional @OneToMany / @ManyToOne
mappedBygoes on the@OneToManyside — it tells JPA that the OTHER side owns the FK- Always maintain both sides — set both the
orderfield and add to theitemslist - Initialize collections —
new ArrayList<>()prevents NPE on empty collections CascadeType.ALL+orphanRemoval = true— the standard for parent-owns-children
Cascade Types
| Cascade Type | When it propagates |
|---|---|
PERSIST | When parent is saved |
MERGE | When parent is merged |
REMOVE | When parent is deleted |
REFRESH | When parent is refreshed |
DETACH | When parent is detached |
ALL | All of the above |
Use CascadeType.ALL + orphanRemoval = true for parent→child relationships where children can’t exist without the parent (compositions). Never cascade REMOVE without orphanRemoval — you’ll have orphan rows.
Unidirectional @OneToMany (avoid if possible)
// AVOID: unidirectional @OneToMany without mappedBy
@OneToMany(cascade = CascadeType.ALL)
@JoinColumn(name = "order_id") // without mappedBy, this creates a join table!
private List<OrderItem> items;
Without mappedBy, Hibernate creates a join table order_items (even with @JoinColumn). Use mappedBy instead.
@ManyToMany — With a Join Table
A Product can be in many Categorys, and a Category can have many Products:
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
private String name;
@ManyToMany
@JoinTable(
name = "product_categories",
joinColumns = @JoinColumn(name = "product_id"),
inverseJoinColumns = @JoinColumn(name = "category_id")
)
private Set<Category> categories = new HashSet<>();
public void addCategory(Category category) {
categories.add(category);
category.getProducts().add(this); // maintain inverse side
}
public void removeCategory(Category category) {
categories.remove(category);
category.getProducts().remove(this);
}
}
@Entity
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
private String name;
@ManyToMany(mappedBy = "categories")
private Set<Product> products = new HashSet<>();
}
@ManyToMany with extra columns on the join table
When the join table needs its own columns (e.g., assigned_at, role), turn it into a proper entity:
// Instead of @ManyToMany, model the join table as an entity
@Entity
@Table(name = "order_promotions")
public class OrderPromotion {
@EmbeddedId
private OrderPromotionId id;
@ManyToOne(fetch = FetchType.LAZY)
@MapsId("orderId")
private Order order;
@ManyToOne(fetch = FetchType.LAZY)
@MapsId("promotionId")
private Promotion promotion;
@Column(name = "applied_at")
private Instant appliedAt;
@Column(name = "discount_amount", precision = 10, scale = 2)
private BigDecimal discountAmount;
}
@Embeddable
public class OrderPromotionId implements Serializable {
private UUID orderId;
private UUID promotionId;
// equals and hashCode required
}
@OneToOne
For relationships where one entity belongs to exactly one other:
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
// Optional one-to-one (FK in orders table)
@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "shipment_id")
private Shipment shipment;
}
@Entity
public class Shipment {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
// Inverse side (no FK here — FK is in orders table)
@OneToOne(mappedBy = "shipment", fetch = FetchType.LAZY)
private Order order;
private String trackingNumber;
private Instant shippedAt;
}
Sharing the primary key (when child’s PK is the same as parent’s PK):
@Entity
public class OrderDetails {
@Id
private UUID id;
@OneToOne(fetch = FetchType.LAZY)
@MapsId // use same PK as Order
@JoinColumn(name = "id")
private Order order;
private String giftMessage;
private boolean giftWrapping;
}
The N+1 Problem — and How to Fix It
The most common JPA performance issue:
// N+1: 1 query for orders + N queries for items (1 per order)
List<Order> orders = orderRepository.findAll();
orders.forEach(o -> {
System.out.println(o.getItems().size()); // triggers SELECT for each order's items
});
This produces SQL:
SELECT * FROM orders; -- 1 query
SELECT * FROM order_items WHERE order_id = 'order1'; -- N queries
SELECT * FROM order_items WHERE order_id = 'order2';
-- ... for every order
Fix 1: JOIN FETCH in JPQL
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.items WHERE o.status = :status")
List<Order> findByStatusWithItems(@Param("status") OrderStatus status);
DISTINCT is needed to deduplicate — a JOIN produces one row per item.
Fix 2: @EntityGraph
@EntityGraph(attributePaths = {"items"})
List<Order> findByStatus(OrderStatus status);
Fix 3: Two separate queries (often best for pagination)
// Query 1: paginated orders (no JOIN)
Page<Order> orderPage = orderRepository.findAll(pageable);
List<UUID> orderIds = orderPage.getContent().stream()
.map(Order::getId).toList();
// Query 2: fetch all items for those orders in one go
List<OrderItem> items = orderItemRepository.findByOrderIdIn(orderIds);
// Map items back to orders in Java
Map<UUID, List<OrderItem>> itemsByOrderId = items.stream()
.collect(groupingBy(item -> item.getOrder().getId()));
orderPage.getContent().forEach(order ->
order.setItems(itemsByOrderId.getOrDefault(order.getId(), List.of()))
);
This is the Hibernate “batch loading” pattern done manually — clean SQL, no cartesian product.
Fix 4: Hibernate @BatchSize
Let Hibernate batch the N queries into IN clauses:
@Entity
public class Order {
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
@BatchSize(size = 50) // loads items for 50 orders at a time with IN (...)
private List<OrderItem> items;
}
Instead of N queries, you get ceil(N/50) queries with WHERE order_id IN (...).
equals() and hashCode() for Entities
Critical for collections to work correctly:
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Order other)) return false;
return id != null && id.equals(other.id);
}
@Override
public int hashCode() {
// Don't use id here — it's null before persist
return getClass().hashCode();
}
}
Rules:
- Only use
idinequals()— but guard against null (transient entities have no ID yet) - Use
getClass().hashCode()(constant) inhashCode()— consistent across persist/transient states - Never put relationship fields in equals/hashCode — causes stack overflow in bidirectional relationships
Complete Example: Order with Items
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "customer_id", nullable = false)
private UUID customerId;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private OrderStatus status = OrderStatus.PENDING;
// Domain methods maintain both sides
public void addItem(OrderItem item) {
items.add(item);
item.setOrder(this);
}
public void removeItem(OrderItem item) {
items.remove(item);
item.setOrder(null);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Order other)) return false;
return id != null && id.equals(other.id);
}
@Override public int hashCode() { return getClass().hashCode(); }
}
@Entity
@Table(name = "order_items")
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id", nullable = false)
private Order order;
@Column(name = "product_id", nullable = false)
private UUID productId;
@Positive
private int quantity;
@Column(precision = 10, scale = 2)
private BigDecimal unitPrice;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof OrderItem other)) return false;
return id != null && id.equals(other.id);
}
@Override public int hashCode() { return getClass().hashCode(); }
}
Usage in service:
@Transactional
public Order addItemToOrder(UUID orderId, AddItemRequest req) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
OrderItem item = new OrderItem();
item.setProductId(req.productId());
item.setQuantity(req.quantity());
item.setUnitPrice(req.unitPrice());
order.addItem(item); // cascade handles the insert
return order; // dirty checking handles the update
}
@Transactional
public Order removeItem(UUID orderId, UUID itemId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
order.getItems().stream()
.filter(i -> i.getId().equals(itemId))
.findFirst()
.ifPresent(order::removeItem); // orphanRemoval handles the delete
return order;
}
What You’ve Learned
@ManyToOne(fetch = LAZY)always — default EAGER is a performance problem@OneToMany(mappedBy = ..., cascade = ALL, orphanRemoval = true)for parent-owns-children- Always maintain both sides of a bidirectional relationship with helper methods
@ManyToManycreates a join table; model it as an entity when you need extra columns- N+1 is the #1 JPA performance problem — fix with JOIN FETCH,
@EntityGraph,@BatchSize, or two-query pattern equals()uses ID only (null-safe);hashCode()usesgetClass().hashCode()(constant)
Next: Article 19 — JPQL, @Query, and Native Queries — write custom queries when derived methods aren’t enough.