Spring Boot
OneToMany
@OneToMany maps a relationship where one entity instance is associated with a collection of another entity's instances — one Order has many OrderItems, one Department has many Employees. In JPA, the @ManyToOne side always owns the foreign key. @OneToMany is almost always the inverse side of a bidirectional relationship, declared with mappedBy.
Bidirectional @OneToMany / @ManyToOne
The standard pattern for one-to-many is bidirectional: the @OneToMany parent declares mappedBy pointing to the @ManyToOne field on the child. The child holds the foreign key column. Always synchronise both sides with a convenience method on the parent.
Java
// ── Parent — the "one" side: ──────────────────────────────────────────
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String reference;
// Inverse side — mappedBy references the field on OrderItem:
@OneToMany(
mappedBy = "order", // field name on OrderItem
cascade = CascadeType.ALL, // persist/delete items with order
orphanRemoval = true, // delete item when removed from list
fetch = FetchType.LAZY // ALWAYS lazy for collections
)
private List<OrderItem> items = new ArrayList<>();
// Convenience methods — always synchronise both sides:
public void addItem(OrderItem item) {
items.add(item);
item.setOrder(this);
}
public void removeItem(OrderItem item) {
items.remove(item);
item.setOrder(null);
}
protected Order() { }
public Order(String reference) { this.reference = reference; }
public Long getId() { return id; }
public String getReference() { return reference; }
public List<OrderItem> getItems() { return Collections.unmodifiableList(items); }
}
// ── Child — the "many" side, owns the FK column: ──────────────────────
@Entity
@Table(name = "order_items")
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// Owning side — order_items.order_id FK column:
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id", nullable = false)
private Order order;
@Column(nullable = false)
private String productName;
@Column(nullable = false)
private int quantity;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal unitPrice;
// equals/hashCode based on business key, NOT id:
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof OrderItem)) return false;
OrderItem that = (OrderItem) o;
return Objects.equals(productName, that.productName)
&& Objects.equals(order, that.order);
}
@Override
public int hashCode() { return Objects.hash(productName); }
protected OrderItem() { }
public OrderItem(String productName, int quantity, BigDecimal unitPrice) {
this.productName = productName;
this.quantity = quantity;
this.unitPrice = unitPrice;
}
public Long getId() { return id; }
public Order getOrder() { return order; }
void setOrder(Order order) { this.order = order; }
public String getProductName() { return productName; }
public int getQuantity() { return quantity; }
public void setQuantity(int quantity) { this.quantity = quantity; }
public BigDecimal getUnitPrice() { return unitPrice; }
}Unidirectional @OneToMany (with Join Table)
A unidirectional @OneToMany without mappedBy causes Hibernate to create a join table by default. This is rarely desirable — it adds an extra table with no corresponding entity and produces unexpected SQL. Prefer the bidirectional pattern or add @JoinColumn to force an FK on the child table.
Java
// ── AVOID: Unidirectional @OneToMany without @JoinColumn ─────────────
// Hibernate creates a join table: users_roles (user_id, roles_id)
@OneToMany(cascade = CascadeType.ALL)
private List<Role> roles = new ArrayList<>();
// ── BETTER: Unidirectional @OneToMany with @JoinColumn ────────────────
// No join table — FK column on the child table:
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true,
fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false) // FK on roles table
private List<Role> roles = new ArrayList<>();
// No need for a convenience method — unidirectional, child has no back-ref
public void addRole(Role role) { roles.add(role); }
public void removeRole(Role role) { roles.remove(role); }
}
// ── BEST: Bidirectional (preferred in almost all cases): ──────────────
// If Role can exist independently and is not a value object,
// make it bidirectional so Role.getUser() is available.
// If Role is a dependent value (no identity outside User),
// consider @ElementCollection instead.
// ── @ElementCollection — for simple value types: ──────────────────────
@Entity
public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// Stores a collection of simple values — no separate entity needed:
@ElementCollection
@CollectionTable(name = "user_phone_numbers",
joinColumns = @JoinColumn(name = "user_id"))
@Column(name = "phone_number")
private List<String> phoneNumbers = new ArrayList<>();
@ElementCollection
@CollectionTable(name = "user_addresses",
joinColumns = @JoinColumn(name = "user_id"))
private List<Address> addresses = new ArrayList<>(); // Address is @Embeddable
}Fetching Strategies and JOIN FETCH
@OneToMany collections are LAZY by default — Hibernate will not load items until they are accessed. Access outside a transaction throws LazyInitializationException. Use JOIN FETCH in JPQL when you need the collection, and use @BatchSize or @Fetch to tune bulk loading.
Java
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
// ── JOIN FETCH — load order with items in one query: ──────────────
@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.id = :id")
Optional<Order> findByIdWithItems(@Param("id") Long id);
// ── JOIN FETCH with pagination — requires countQuery: ─────────────
@Query(value = "SELECT DISTINCT o FROM Order o LEFT JOIN FETCH o.items",
countQuery = "SELECT COUNT(o) FROM Order o")
Page<Order> findAllWithItems(Pageable pageable);
// ── EntityGraph — alternative to JOIN FETCH: ──────────────────────
@EntityGraph(attributePaths = {"items"})
@Query("SELECT o FROM Order o WHERE o.reference = :ref")
Optional<Order> findByReferenceWithItems(@Param("ref") String ref);
}
// ── @BatchSize — load N collections per query instead of 1: ──────────
// Instead of 1 SELECT per order for items (N+1),
// Hibernate loads items for N orders in one IN (...) query.
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
@BatchSize(size = 25) // SELECT ... WHERE order_id IN (1, 2, 3, ... 25)
private List<OrderItem> items = new ArrayList<>();
// ── Global batch fetch size in application.yml (recommended): ─────────
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 25
// Applies to ALL lazy collections and proxies — often the easiest N+1 fix.Cascade and OrphanRemoval
CascadeType.ALL with orphanRemoval = true is the correct setting when the child's lifecycle is fully controlled by the parent. Use it for true parent-child relationships (Order/OrderItem). Do not use it for relationships between independent entities.
Java
// ── CascadeType.ALL + orphanRemoval — for owned children: ────────────
// Order owns its items — items cannot exist without an order.
@OneToMany(mappedBy = "order",
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY)
private List<OrderItem> items = new ArrayList<>();
// Persisting an order persists its items:
Order order = new Order("REF-001");
order.addItem(new OrderItem("Widget", 2, new BigDecimal("9.99")));
order.addItem(new OrderItem("Gadget", 1, new BigDecimal("49.99")));
orderRepository.save(order); // INSERT order + 2 INSERT order_items
// Adding an item to an existing order:
Order order = orderRepository.findByIdWithItems(1L).orElseThrow();
order.addItem(new OrderItem("Donut", 6, new BigDecimal("1.50")));
orderRepository.save(order); // INSERT 1 new order_item
// Removing an item — orphanRemoval deletes it:
Order order = orderRepository.findByIdWithItems(1L).orElseThrow();
OrderItem toRemove = order.getItems().get(0);
order.removeItem(toRemove);
orderRepository.save(order); // DELETE FROM order_items WHERE id = ?
// Deleting the order — cascade deletes all items:
orderRepository.deleteById(1L);
// DELETE FROM order_items WHERE order_id = 1
// DELETE FROM orders WHERE id = 1
// ── No cascade — for independent relationships: ────────────────────────
// Department has many Employees, but employees exist independently.
@OneToMany(mappedBy = "department", fetch = FetchType.LAZY)
// No cascade, no orphanRemoval — employees survive department deletion.
private List<Employee> employees = new ArrayList<>();