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) or EAGER (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

  1. mappedBy goes on the @OneToMany side — it tells JPA that the OTHER side owns the FK
  2. Always maintain both sides — set both the order field and add to the items list
  3. Initialize collectionsnew ArrayList<>() prevents NPE on empty collections
  4. CascadeType.ALL + orphanRemoval = true — the standard for parent-owns-children

Cascade Types

Cascade TypeWhen it propagates
PERSISTWhen parent is saved
MERGEWhen parent is merged
REMOVEWhen parent is deleted
REFRESHWhen parent is refreshed
DETACHWhen parent is detached
ALLAll 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 id in equals() — but guard against null (transient entities have no ID yet)
  • Use getClass().hashCode() (constant) in hashCode() — 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
  • @ManyToMany creates 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() uses getClass().hashCode() (constant)

Next: Article 19 — JPQL, @Query, and Native Queries — write custom queries when derived methods aren’t enough.