Spring Boot
OneToMany
@OneToMany maps a parent that owns a collection of children: one Order has many OrderItems, one Department has many Employees. In the database there is no collection, only a foreign key on the child table, so JPA treats the @ManyToOne side as the owner of the relationship and the @OneToMany side as a read-only mirror declared with mappedBy. Most of the trouble people run into, such as join tables nobody wanted, N+1 queries, LazyInitializationException and surprise deletes, comes from forgetting that asymmetry.
Who Owns the Relationship
In a relational schema the link between an order and its items is a single order_id column on the order_items table. Nothing on the orders table records which items belong to it. JPA mirrors that: the entity whose table holds the foreign key, the @ManyToOne side, is the owning side, and Hibernate only looks at the owning side when it writes the foreign key.
The @OneToMany side is the inverse. You tell Hibernate that with mappedBy, naming the field on the child that does the real work. Leave mappedBy out and Hibernate assumes this side owns the relationship too, so it invents a join table (something like orders_order_items) to store the links. That extra table, and the extra SQL that goes with it, is the most common first surprise with this mapping.
A consequence worth remembering: adding a child to the parent's list does not save the relationship on its own. Only item.setOrder(order) sets the foreign key. That is why the parent gets helper methods that update both sides together.
Java
// What mappedBy changes:
// Without mappedBy: Hibernate creates a join table and manages both sides itself.
@OneToMany
private List<OrderItem> items; // tables: orders, order_items, orders_order_items
// With mappedBy: the foreign key lives on order_items.order_id, owned by OrderItem.order.
@OneToMany(mappedBy = "order")
private List<OrderItem> items; // tables: orders, order_items (order_id FK)The Bidirectional Mapping
This is the shape to reach for by default. The parent exposes the collection and helper methods; the child holds the foreign key and a lazy reference back.
Several choices in the code are deliberate. Collection fetching is already LAZY by default, so writing it is only documentation, but @ManyToOne is EAGER by default, which is why it is set to LAZY explicitly. The collection is exposed as an unmodifiable view so callers have to go through addItem and removeItem and cannot desynchronise the two sides. Ids use a sequence rather than IDENTITY, because IDENTITY forces Hibernate to insert each row immediately and switches off JDBC batching, which hurts as soon as one order has more than a few items.
equals and hashCode deserve care. The tempting approach is to base them on the generated id, but the id is null until the entity is saved, which breaks anything stored in a HashSet before that point. Basing them on a business key such as productName fails the first time an order contains the same product twice. The pattern below is the widely used compromise: two persisted items are equal when their ids match, and a new item is equal only to itself. The hash code is a constant per class so it does not change when the id is assigned.
Java
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
@Column(nullable = false, unique = true)
private String reference;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
protected Order() { } // required by JPA
public Order(String reference) {
this.reference = reference;
}
// Keep both sides in step. These are the only way items should change.
public void addItem(OrderItem item) {
items.add(item);
item.setOrder(this);
}
public void removeItem(OrderItem item) {
items.remove(item);
item.setOrder(null);
}
public Long getId() { return id; }
public String getReference() { return reference; }
public List<OrderItem> getItems() { return Collections.unmodifiableList(items); }
}
@Entity
@Table(name = "order_items")
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
// Owning side: this is the order_id column. @ManyToOne is EAGER unless told otherwise.
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@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;
protected OrderItem() { }
public OrderItem(String productName, int quantity, BigDecimal unitPrice) {
this.productName = productName;
this.quantity = quantity;
this.unitPrice = unitPrice;
}
// Identity: equal when both are persisted and share an id; a new item equals only itself.
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof OrderItem other)) return false;
return id != null && id.equals(other.getId()); // getId(), so lazy proxies work
}
@Override
public int hashCode() {
return getClass().hashCode(); // stable before and after the id is assigned
}
public Long getId() { return id; }
public Order getOrder() { return order; }
void setOrder(Order order) { this.order = order; } // package-private: Order controls it
public String getProductName() { return productName; }
public int getQuantity() { return quantity; }
public void setQuantity(int quantity) { this.quantity = quantity; }
public BigDecimal getUnitPrice() { return unitPrice; }
}Using It in a Service
Do the work inside a @Transactional method. Entities loaded in a transaction are managed, which means Hibernate watches them and writes any changes when the transaction commits. You do not need to call save on an order you just loaded, and calling it anyway is harmless but misleading. Only new entities need save.
The cascade settings do the rest. Saving a new order inserts its items. Removing an item from the collection deletes its row because of orphanRemoval. Deleting the order deletes the items first, and note that Hibernate does this one DELETE per item, not a single statement, because it has to load and remove each child to honour the cascade. For an order with thousands of items, use a bulk @Modifying delete query instead and accept that it bypasses cascades and lifecycle callbacks.
Java
@Service
public class OrderService {
private final OrderRepository orders;
public OrderService(OrderRepository orders) {
this.orders = orders;
}
@Transactional
public Long place(String reference, List<NewItem> newItems) {
Order order = new Order(reference);
newItems.forEach(i ->
order.addItem(new OrderItem(i.productName(), i.quantity(), i.unitPrice())));
return orders.save(order).getId(); // 1 insert for the order, then one per item
}
@Transactional
public void addItem(Long orderId, NewItem item) {
Order order = orders.findByIdWithItems(orderId).orElseThrow();
order.addItem(new OrderItem(item.productName(), item.quantity(), item.unitPrice()));
// No save() call: the order is managed, so the new row is inserted at commit.
}
@Transactional
public void removeItem(Long orderId, Long itemId) {
Order order = orders.findByIdWithItems(orderId).orElseThrow();
order.getItems().stream()
.filter(i -> i.getId().equals(itemId))
.findFirst()
.ifPresent(order::removeItem); // orphanRemoval issues the DELETE at commit
}
@Transactional
public void delete(Long orderId) {
orders.deleteById(orderId); // loads the order, deletes each item, then the order
}
}Unidirectional Mappings and Value Collections
Sometimes the child has no reason to know about its parent. A unidirectional @OneToMany is legal, but the default produces a join table, and even with a join column Hibernate cannot be as efficient as the bidirectional version: it typically inserts the child first and then runs an UPDATE to set the foreign key. Setting nullable = false on the @JoinColumn lets it include the key in the original insert and avoids that extra statement. Prefer a Set, or add @OrderColumn if order matters, because a plain unordered List is treated as a bag and Hibernate can resort to deleting and re-inserting rows to apply changes.
If the "children" are not really entities at all, such as phone numbers or a list of addresses with no identity or life of their own, use @ElementCollection. It stores simple values or embeddables in their own table, with no entity class and no cascade configuration. The trade-off is that the rows have no identity, so changing one element can cause Hibernate to rewrite the whole collection. It suits small, rarely changed lists.
Java
// Unidirectional, with a join column: no join table, but needs nullable = false
// to avoid insert-then-update.
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "user_id", nullable = false) // FK lives on the roles table
private Set<Role> roles = new HashSet<>();
}
// Value collections: no entity, no cascade, rows have no identity.
@Entity
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
@ElementCollection
@CollectionTable(name = "customer_phone_numbers",
joinColumns = @JoinColumn(name = "customer_id"))
@Column(name = "phone_number", nullable = false)
private Set<String> phoneNumbers = new HashSet<>();
@ElementCollection
@CollectionTable(name = "customer_addresses",
joinColumns = @JoinColumn(name = "customer_id"))
private List<Address> addresses = new ArrayList<>(); // Address is @Embeddable
}Loading Collections: JOIN FETCH and the Pagination Trap
Because the collection is lazy, touching order.getItems() after the transaction has ended throws LazyInitializationException. The fix is to load what you need while the transaction is open, not to switch to EAGER, which makes every query on orders drag its items along whether or not the caller wants them.
JOIN FETCH loads the parent and its collection in a single query. Use LEFT JOIN FETCH unless you are sure every parent has children: a plain JOIN FETCH is an inner join and silently returns nothing for an order without items. In Hibernate 6 the parent entities are de-duplicated automatically, so the old DISTINCT workaround is no longer needed.
There are two limits to know. First, never combine JOIN FETCH with Pageable on a collection. Hibernate cannot apply LIMIT to a joined result without cutting a parent's children in half, so it fetches every row, paginates in memory and logs warning HHH90003004 (or HHH000104 on older versions). On a large table that means loading everything into the heap. Page the parent ids first, then fetch the collections for just those ids. Second, fetching two List collections in one query throws MultipleBagFetchException. Fetch one collection per query, or use Sets.
Java
public interface OrderRepository extends JpaRepository<Order, Long> {
// LEFT JOIN so orders with no items are still returned.
@Query("select o from Order o left join fetch o.items where o.id = :id")
Optional<Order> findByIdWithItems(@Param("id") Long id);
// Same idea via an entity graph, handy with derived queries.
@EntityGraph(attributePaths = "items")
Optional<Order> findByReference(String reference);
// Paging safely: step 1, page the ids (a plain, fast query, paginated in SQL).
@Query("select o.id from Order o")
Page<Long> findIds(Pageable pageable);
// Step 2, fetch the items for only those orders.
@Query("select o from Order o left join fetch o.items where o.id in :ids")
List<Order> findAllWithItemsByIdIn(@Param("ids") List<Long> ids);
}
// Usage:
// Page<Long> ids = orderRepository.findIds(PageRequest.of(0, 20, Sort.by("id")));
// List<Order> orders = orderRepository.findAllWithItemsByIdIn(ids.getContent());
// // The second query does not preserve page order: sort the result by id before returning it.
// Anti-pattern: this paginates in memory and logs a warning.
// @Query("select o from Order o left join fetch o.items")
// Page<Order> findAllWithItems(Pageable pageable);Fixing N+1 with Batch Fetching
The classic N+1 problem: you load 50 orders with one query, then touch each order's items, and Hibernate runs 50 more queries, one per order. Writing a JOIN FETCH for every access pattern is not always practical, especially when pagination is involved.
Batch fetching is the low-effort alternative. When Hibernate needs the items for one order, it also loads the items for up to N other orders already in the session, using a single IN query. The 51 queries become 3 or 4. Setting it globally covers every lazy collection and proxy in the application, and it is usually the first thing to try before rewriting queries. Turning on SQL logging in development is the quickest way to see whether you have an N+1 problem at all.
yaml
spring:
jpa:
open-in-view: false # see the pitfalls section; avoids hidden lazy loading in controllers
properties:
hibernate:
default_batch_fetch_size: 25 # load lazy collections for up to 25 parents per query
logging:
level:
org.hibernate.SQL: DEBUG # count the statements per request to spot N+1 early
# Per-collection alternative, on the field itself:
# @OneToMany(mappedBy = "order")
# @BatchSize(size = 25)
# private List<OrderItem> items = new ArrayList<>();Cascade and orphanRemoval: When to Use Them
cascade = ALL with orphanRemoval = true says that the children belong to the parent: they are created with it, removed from the database when removed from its list, and deleted when it is. That is right for composition, where the child makes no sense without the parent: order items, invoice lines, comments on a post.
It is wrong for associations between independent entities. A department does not own its employees; deleting a department should fail or reassign people, not delete them. Leave cascade off there, and expect the foreign key constraint to protect you. Be especially careful with CascadeType.REMOVE on anything shared, since a delete will quietly follow the relationship into rows other things still depend on.
One more design question to ask before writing the collection at all: should the parent have it? A customer with 200,000 orders should not carry an orders list, because nothing sensible can load it. Dropping the @OneToMany and querying the children through their own repository (orderRepository.findByCustomerId(id, pageable)) is often the better model, and you still keep the @ManyToOne on the child.
Java
// Composition: the parent owns the lifecycle.
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
// Association between independent entities: no cascade, no orphanRemoval.
@OneToMany(mappedBy = "department")
private List<Employee> employees = new ArrayList<>();
// Unbounded children: skip the collection, query by the child's foreign key instead.
public interface OrderRepository extends JpaRepository<Order, Long> {
Page<Order> findByCustomerId(Long customerId, Pageable pageable);
}
// A caution: orphanRemoval only works when you modify the existing collection.
// Replacing it breaks the mapping and Hibernate throws an exception:
// order.setItems(new ArrayList<>(newItems)); // don't
// order.getItems().clear(); newItems.forEach(order::addItem); // do (via helpers)Pitfalls Checklist
Do not return entities from controllers. Serialising an Order with Jackson walks into items, and each item points back to its order, which recurses forever or triggers lazy loading outside the transaction. Map to DTOs or records in the service and return those.
Watch Lombok. @Data and @EqualsAndHashCode generate methods that include every field, so equals and hashCode touch the lazy collection (forcing a load) and toString on a bidirectional pair recurses until the stack overflows. Use @Getter and write equals, hashCode and toString yourself, or exclude the relationship fields.
Set spring.jpa.open-in-view to false. Spring Boot keeps a session open for the whole web request by default, which hides LazyInitializationException during development and lets queries run from the view layer in production. Turning it off forces data loading to be explicit and visible.
Finally, always check the SQL. A mapping that works can still issue ten times the statements you expect, and the difference only shows up in the log or under load.
Java
// Return a DTO, not the entity:
public record OrderDto(Long id, String reference, List<ItemDto> items) {
public static OrderDto from(Order order) { // call inside @Transactional
return new OrderDto(
order.getId(),
order.getReference(),
order.getItems().stream().map(ItemDto::from).toList());
}
}
public record ItemDto(String productName, int quantity, BigDecimal unitPrice) {
public static ItemDto from(OrderItem i) {
return new ItemDto(i.getProductName(), i.getQuantity(), i.getUnitPrice());
}
}
// Lombok on entities: avoid @Data. If you must, keep relationships out of the generated methods.
// @Getter
// @ToString(exclude = "items")
// @EqualsAndHashCode(onlyExplicitlyIncluded = true)