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 methodsaddItem() 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.