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:

Java
@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<>();
}
Java
@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;
}
Java
@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;
}
  • @ManyToOne is the owning side of the relationship — it holds the actual foreign key column (customer_id, order_id).
  • @OneToMany(mappedBy = "customer") is the inverse sidemappedBy points at the field on the owning side that maps this relationship, and no extra column is created for it.
  • cascade = CascadeType.ALL propagates persist/merge/remove operations from the parent to its children — saving a Customer with new Orders in its list saves the orders too, without calling orderRepository.save(...) separately.
  • orphanRemoval = true deletes 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

Java
@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<>();
}
Java
@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:

Java
@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/@OneToOne at their spec-default EAGER fetch type, silently pulling in related entities on every query that touches the owning entity, whether needed or not.
  • Forgetting @Transactional on a service method that performs multiple related writes, leaving a partial, inconsistent write if a later step fails.
  • Using CascadeType.ALL on 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.