Spring BootOneToMany
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.

// ── @ElementCollectionfor 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<>();