One-to-Many and Many-to-One: The Most Common Relationship

Introduction

The one-to-many relationship is the most common in any domain model. An Order has many OrderItems. A Category has many Products. A Customer has many Orders. Understanding @OneToMany and @ManyToOne well — especially bidirectional mapping, collection types, and cascade configuration — is foundational to any JPA application.


The Domain Example

orders (1) ──────────────── (*) order_items

An order has many order items. Each order item belongs to exactly one order.


@ManyToOne — The Child Side

Always start with @ManyToOne. It is simpler and always holds the foreign key:

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

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "order_id", nullable = false)
    private Order order;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "product_id", nullable = false)
    private Product product;

    @Column(nullable = false)
    private Integer quantity = 1;

    @Column(name = "unit_price", nullable = false, precision = 10, scale = 2)
    private BigDecimal unitPrice;
}

@ManyToOne(fetch = FetchType.LAZY) is critical — the default is EAGER, which loads the Order and Product every time you load an OrderItem. Always override to LAZY.

@JoinColumn(name = "order_id") places the foreign key column in the order_items table.


@OneToMany — The Parent Side

@Entity
@Table(name = "orders")
@Getter @Setter @NoArgsConstructor
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "customer_id", nullable = false)
    private Customer customer;

    @Enumerated(EnumType.STRING)
    @Column(length = 20, nullable = false)
    private OrderStatus status = OrderStatus.PENDING;

    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal total = BigDecimal.ZERO;

    @Column(name = "ordered_at", nullable = false)
    private LocalDateTime orderedAt;

    // Bidirectional @OneToMany — mappedBy references the 'order' field in OrderItem
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderItem> items = new ArrayList<>();

    // ── Helper methods ──────────────────────────────────────────────────────

    public void addItem(OrderItem item) {
        items.add(item);
        item.setOrder(this);  // sync both sides
    }

    public void removeItem(OrderItem item) {
        items.remove(item);
        item.setOrder(null);  // sync both sides
    }
}

Key points

  1. mappedBy = "order" — this tells Hibernate “the foreign key is on the order field in OrderItem”. The @OneToMany side does NOT create any column — the column is on OrderItem.

  2. cascade = CascadeType.ALL — saving an Order also saves all its OrderItem children.

  3. orphanRemoval = true — removing an item from the items list automatically deletes it from the database. Without this, items.remove(item) only detaches the item from the collection in Java — the row stays in the database.

  4. Helper methods — addItem() and removeItem() maintain both sides of the bidirectional relationship. Never manipulate items directly without also updating item.setOrder(...).


Creating an Order with Items

@Transactional
public Order placeOrder(Long customerId, List<OrderItemRequest> requests) {
    Customer customer = customerRepository.findById(customerId).orElseThrow();

    Order order = new Order();
    order.setCustomer(customer);
    order.setOrderedAt(LocalDateTime.now());
    order.setCreatedAt(LocalDateTime.now());
    order.setUpdatedAt(LocalDateTime.now());

    BigDecimal total = BigDecimal.ZERO;

    for (OrderItemRequest req : requests) {
        Product product = productRepository.findById(req.productId()).orElseThrow();

        OrderItem item = new OrderItem();
        item.setProduct(product);
        item.setQuantity(req.quantity());
        item.setUnitPrice(product.getPrice());
        item.setCreatedAt(LocalDateTime.now());

        order.addItem(item);  // uses helper method — sets item.order = order
        total = total.add(product.getPrice().multiply(BigDecimal.valueOf(req.quantity())));
    }

    order.setTotal(total);
    return orderRepository.save(order);
    // CascadeType.ALL: saves order + all items in one transaction
}

Hibernate generates:

INSERT INTO orders (customer_id, status, total, ordered_at, ...) VALUES (?, ?, ?, ?, ...)
INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES (?, ?, ?, ?)
INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES (?, ?, ?, ?)
-- one INSERT per item

Removing Items with orphanRemoval

@Transactional
public void removeItem(Long orderId, Long itemId) {
    Order order = orderRepository.findById(orderId).orElseThrow();
    order.getItems().removeIf(item -> item.getId().equals(itemId));
    // orphanRemoval = true: the removed item's row is deleted automatically on flush
}

Without orphanRemoval = true, removing from the list only breaks the in-memory association. The order_items row stays in the database with order_id set to NULL (if nullable) or causes an error.


Unidirectional @OneToMany — Why You Should Avoid It

You can define @OneToMany without a @ManyToOne on the other side:

// Unidirectional @OneToMany — avoid in most cases
@OneToMany(cascade = CascadeType.ALL)
@JoinColumn(name = "order_id")  // forces FK on order_items
private List<OrderItem> items;

This looks simpler but has a hidden cost: Hibernate manages the join column using separate UPDATE statements after the INSERT:

INSERT INTO order_items (quantity, unit_price) VALUES (?, ?)
UPDATE order_items SET order_id=? WHERE id=?  -- extra UPDATE per item!

This is less efficient than bidirectional, where the FK is set in the original INSERT.

Recommendation: Use bidirectional @OneToMany + @ManyToOne — it is more efficient and clearer.


@OneToMany with a Set

Using Set instead of List avoids duplicate items and can be more efficient for certain operations:

@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<OrderItem> items = new HashSet<>();

But Set requires correct equals()/hashCode() on OrderItem. For entities, implement equals based on a natural key or the business identifier:

@Entity
public class OrderItem {
    // equals/hashCode based on order + product (the natural key)
    @Override
    public boolean equals(Object o) {
        if (!(o instanceof OrderItem)) return false;
        OrderItem other = (OrderItem) o;
        return Objects.equals(order, other.order) &&
               Objects.equals(product, other.product);
    }

    @Override
    public int hashCode() {
        return Objects.hash(order, product);
    }
}

List is fine for ordered collections or when duplicates are semantically meaningful. Set is better when uniqueness is enforced.


Category and Products (Many-to-One)

Another example from the domain — a product belongs to one category, a category has many products:

@Entity
@Table(name = "products")
public class Product {

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "category_id")
    private Category category;
}
@Entity
@Table(name = "categories")
public class Category {

    @OneToMany(mappedBy = "category", fetch = FetchType.LAZY)
    private List<Product> products = new ArrayList<>();
}

Here orphanRemoval = false — products can exist without a category (or be moved to another category). Only use orphanRemoval = true for tightly owned children (like order items) that have no meaning outside the parent.


// Load an order with its items in a single query using JOIN FETCH
@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.id = :id")
Optional<Order> findWithItems(@Param("id") Long id);

Without JOIN FETCH, loading order.getItems() triggers a second SQL query (lazy load). With JOIN FETCH, everything comes back in one query:

SELECT o.*, oi.*
FROM orders o
INNER JOIN order_items oi ON oi.order_id = o.id
WHERE o.id = ?

The N+1 problem and fetch strategies are covered in depth in Articles 26–27.


Key Takeaways

  • @ManyToOne(fetch = FetchType.LAZY) on the child side holds the foreign key column — always set LAZY
  • @OneToMany(mappedBy = "...") on the parent side references the child’s field — creates no column
  • orphanRemoval = true deletes child rows from the DB when they are removed from the parent’s collection
  • cascade = CascadeType.ALL on @OneToMany means saving the parent automatically saves children
  • Always use helper methods (addItem, removeItem) to keep both sides of a bidirectional relationship in sync
  • Avoid unidirectional @OneToMany — it generates inefficient extra UPDATE statements

What’s Next

Article 11 covers @ManyToMany — how to map the product-tag relationship, define the join table, manage ownership, and handle the common problem of adding extra data to a join table.