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
mappedBy = "order"— this tells Hibernate “the foreign key is on theorderfield inOrderItem”. The@OneToManyside does NOT create any column — the column is onOrderItem.cascade = CascadeType.ALL— saving anOrderalso saves all itsOrderItemchildren.orphanRemoval = true— removing an item from theitemslist 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.Helper methods —
addItem()andremoveItem()maintain both sides of the bidirectional relationship. Never manipulateitemsdirectly without also updatingitem.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.
Loading Related Collections
// 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 setLAZY@OneToMany(mappedBy = "...")on the parent side references the child’s field — creates no columnorphanRemoval = truedeletes child rows from the DB when they are removed from the parent’s collectioncascade = CascadeType.ALLon@OneToManymeans 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.