Relationships & Transactions
@OneToMany, @ManyToOne and @ManyToMany mappings, @Transactional, and lazy vs eager loading trade-offs.
@ManyToOne and @OneToMany
A real domain almost always has related entities. Consider orders that belong to a customer, and contain multiple line items:
@Entity
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String email;
@OneToMany(mappedBy = "customer", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Order> orders = new ArrayList<>();
}
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
private BigDecimal total;
}
@Entity
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;
private String productName;
private int quantity;
private BigDecimal unitPrice;
}
@ManyToOneis the owning side of the relationship — it holds the actual foreign key column (customer_id,order_id).@OneToMany(mappedBy = "customer")is the inverse side —mappedBypoints at the field on the owning side that maps this relationship, and no extra column is created for it.cascade = CascadeType.ALLpropagates persist/merge/remove operations from the parent to its children — saving aCustomerwith newOrders in its list saves the orders too, without callingorderRepository.save(...)separately.orphanRemoval = truedeletes a child automatically when it's removed from the parent's collection (e.g.order.getItems().remove(item)), not just when the parent itself is deleted.
@ManyToMany
@Entity
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToMany
@JoinTable(
name = "student_course",
joinColumns = @JoinColumn(name = "student_id"),
inverseJoinColumns = @JoinColumn(name = "course_id")
)
private Set<Course> courses = new HashSet<>();
}
@Entity
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToMany(mappedBy = "courses")
private Set<Course> students = new HashSet<>();
}
@JoinTable describes the join table backing the many-to-many relationship (student_course, with student_id/course_id columns) — this table is managed entirely by JPA and doesn't need its own entity class for a simple relationship with no extra columns of its own. If the join table needs additional data (e.g. an enrollment date), model it as its own entity with two @ManyToOne relationships instead.
@Transactional
A @Transactional method runs inside a single database transaction — either every write inside it commits together, or (on an exception) every write inside it rolls back together:
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final InventoryService inventoryService;
public OrderService(OrderRepository orderRepository, InventoryService inventoryService) {
this.orderRepository = orderRepository;
this.inventoryService = inventoryService;
}
@Transactional
public Order placeOrder(Long customerId, List<OrderItem> items) {
inventoryService.reserveStock(items); // if this throws, the whole method rolls back —
Order order = new Order(customerId, items); // nothing here is committed either
return orderRepository.save(order);
}
}
By default, @Transactional rolls back on unchecked exceptions (RuntimeException) but not on checked exceptions unless told to (@Transactional(rollbackFor = Exception.class)) — a common surprise for anyone expecting all exceptions to trigger a rollback automatically.
@Transactional is typically placed on service-layer methods, not repository or controller methods — a service method represents one coherent business operation, which is exactly the unit that should succeed or fail atomically.
Lazy vs eager loading
FetchType.LAZY means the related entity/collection is loaded only when it's actually accessed for the first time — Spring Data JPA fetches only the row you asked for up front, and issues a second query the moment order.getCustomer().getEmail() is called. FetchType.EAGER loads the association immediately, as part of the original query (often via a join).
LAZY |
EAGER |
|
|---|---|---|
| When loaded | On first access, via an extra query | Immediately, with the owning entity |
Default for @ManyToOne/@OneToOne |
EAGER (JPA spec default — but almost always overridden to LAZY in practice) |
— |
Default for @OneToMany/@ManyToMany |
LAZY |
— |
| Risk | Requires an active persistence context when accessed — LazyInitializationException if accessed after the session/transaction closed |
Can pull back much more data than a given use case actually needs |
The practical convention: default every relationship to LAZY explicitly (including @ManyToOne, overriding its EAGER default), and fetch what a specific use case needs explicitly — either by accessing it inside an active transaction, or with a JPQL JOIN FETCH when you know up front you'll need the association.
Common mistakes
- Accessing a lazy relationship after the transaction/persistence context that loaded the parent entity has already closed (e.g. in a view layer, after the service method returned) — throws
LazyInitializationException. - Leaving
@ManyToOne/@OneToOneat their spec-defaultEAGERfetch type, silently pulling in related entities on every query that touches the owning entity, whether needed or not. - Forgetting
@Transactionalon a service method that performs multiple related writes, leaving a partial, inconsistent write if a later step fails. - Using
CascadeType.ALLon a@ManyToOne(the "many" side cascading operations onto its single parent) — cascading from child to parent is rarely intended and can delete a shared parent unexpectedly.