JPA Relationships and the N+1 Problem
Relationships connect entities. Wrong fetch settings are the most common cause of slow Spring Boot applications, so understand lazy loading and the N+1 problem well.
One-to-many and many-to-one
@Entity
public class Customer {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "customer", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderEntity> orders = new ArrayList<>();
public void addOrder(OrderEntity order) { // keep both sides in sync
orders.add(order);
order.setCustomer(this);
}
public void removeOrder(OrderEntity order) {
orders.remove(order);
order.setCustomer(null);
}
}
@Entity
@Table(name = "orders")
public class OrderEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String item;
@ManyToOne(fetch = FetchType.LAZY) // always set LAZY explicitly
@JoinColumn(name = "customer_id") // the foreign key column (owning side)
private Customer customer;
public void setCustomer(Customer customer) { this.customer = customer; }
}Many-to-many
@Entity
public class Student {
@Id @GeneratedValue 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 private Long id;
@ManyToMany(mappedBy = "courses")
private Set<Student> students = new HashSet<>();
}| Cascade type | Effect |
|---|---|
| PERSIST | Saving the parent saves the children |
| MERGE | Merging the parent merges the children |
| REMOVE | Deleting the parent deletes the children |
| ALL | All of the above |
| orphanRemoval=true | Deletes a child removed from the parent collection |
| Fetch type | Default for | Meaning |
|---|---|---|
| LAZY | @OneToMany, @ManyToMany | Loaded only when you access it |
| EAGER | @ManyToOne, @OneToOne | Loaded together with the owner |
Recommendation: make every association LAZY, and load data explicitly (join fetch or entity graph) when a use case needs it.
The N+1 problem
You load 1 list of 100 customers (1 query). Then for each customer you touch customer.getOrders(), and Hibernate runs one more query per customer (100 queries). Total: 1 + 100 = 101 queries.
select * from customer; ← 1 query
select * from orders where customer_id = 1; ← N queries, one for each customer
select * from orders where customer_id = 2;
select * from orders where customer_id = 3;
... enable spring.jpa.show-sql=true to see themFixes
public interface CustomerRepository extends JpaRepository<Customer, Long> {
// 1. JOIN FETCH: one query with a join
@Query("select distinct c from Customer c join fetch c.orders")
List<Customer> findAllWithOrders();
// 2. Entity graph: same effect, no JPQL
@EntityGraph(attributePaths = "orders")
List<Customer> findByNameStartingWith(String prefix);
}# 3. Batch fetching: load lazy collections in groups (1 query for 50 customers)
spring.jpa.properties.hibernate.default_batch_fetch_size=50
# 4. Do not keep the session open during the view rendering
spring.jpa.open-in-view=false- 4. Use DTO projections when you only need a few columns.
- Do not JOIN FETCH two collections in one query. It creates a huge cartesian product and Hibernate may fail with MultipleBagFetchException.
LazyInitializationException
It happens when you touch a lazy field after the transaction (session) is closed, typically in the controller. Fix it by loading the data inside the service method (join fetch, entity graph) and returning a DTO. Do not fix it by making everything EAGER.
Bidirectional relationship: the rules
- The side WITHOUT mappedBy is the owner and writes the foreign key. Changing only the inverse side (the list) does not update the database.
- Always keep both sides in sync with helper methods (addOrder, removeOrder).
- Use Set for @ManyToMany. A List (bag) with several fetch joins causes MultipleBagFetchException and duplicate rows.
- Use orphanRemoval=true only for real parent-child ownership (order and its lines). Never for shared entities.
equals and hashCode for entities
@Entity
public class OrderLine {
@Id @GeneratedValue(strategy = GenerationType.SEQUENCE) private Long id;
private String sku;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof OrderLine other)) return false; // instanceof works with Hibernate proxies (getClass would not)
return id != null && id.equals(other.id); // two NEW objects (id null) are never equal
}
@Override
public int hashCode() { return getClass().hashCode(); } // constant: stable before and after persist, safe in HashSets
}One-to-one with a shared primary key (@MapsId)
@Entity
public class UserProfile {
@Id private Long id; // same value as the user id, no separate id generation
@OneToOne(fetch = FetchType.LAZY)
@MapsId
@JoinColumn(name = "id")
private AppUser user;
private String bio;
}
// table user_profile(id primary key references app_users(id)). Loading profile by user id is one primary key lookup.Lazy @OneToOne on the inverse side (mappedBy) cannot really be lazy in Hibernate because it must check whether the row exists. Put the foreign key or @MapsId on the side you load most, or use a projection.
Many-to-many with extra columns: an association entity
@Embeddable
public class EnrollmentId implements Serializable {
private Long studentId;
private Long courseId;
protected EnrollmentId() { }
public EnrollmentId(Long studentId, Long courseId) { this.studentId = studentId; this.courseId = courseId; }
@Override public boolean equals(Object o) { return o instanceof EnrollmentId e && Objects.equals(studentId, e.studentId) && Objects.equals(courseId, e.courseId); }
@Override public int hashCode() { return Objects.hash(studentId, courseId); }
}
@Entity
public class Enrollment {
@EmbeddedId private EnrollmentId id;
@ManyToOne(fetch = FetchType.LAZY) @MapsId("studentId") private Student student;
@ManyToOne(fetch = FetchType.LAZY) @MapsId("courseId") private Course course;
private LocalDate enrolledOn; // extra data on the relationship
private Integer grade;
}
// Student has @OneToMany(mappedBy = "student") Set<Enrollment>. A plain @ManyToMany cannot hold enrolledOn or grade.Ordering and collection mapping
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("position ASC") // SQL order by when loading
private List<OrderLine> lines = new ArrayList<>();
@ElementCollection
@CollectionTable(name = "customer_tags", joinColumns = @JoinColumn(name = "customer_id"))
@Column(name = "tag")
private Set<String> tags = new HashSet<>(); // simple values, no entity neededCascade and delete behavior: what really happens
| Action | Result |
|---|---|
| repository.delete(customer) with CascadeType.REMOVE on orders | Hibernate loads the orders, deletes each one, then the customer (many statements) |
| Bulk JPQL delete: delete from Order o where o.customer.id = :id | One statement. Bypasses cascades, lifecycle callbacks and the persistence context |
| Database ON DELETE CASCADE with @OnDelete(action = CASCADE) | One statement and the database removes the children. Fastest for large children sets |
| orphanRemoval=true and list.remove(child) | The removed child row is deleted at flush |
Performance rules for relations
| Rule | Reason |
|---|---|
| ToOne associations LAZY | Default EAGER loads owners with every query and creates N+1 |
| Fetch collections in the use case (join fetch or entity graph) | Load exactly what the screen or API needs |
| Never join fetch two collections at once | Cartesian product. Use Set, or two queries, or batch fetch size |
| Return DTO projections for read-only lists | No entity tracking, one flat query |
| Count with a count query, not size() on a lazy collection | size() loads the whole collection unless @LazyCollection(EXTRA) |
Common mistake: Making associations EAGER to avoid LazyInitializationException. This loads far more data than needed and creates N+1 problems everywhere. Keep LAZY and fetch what each use case needs.
Interview questions
What does mappedBy mean?
It marks the inverse (non-owning) side of the relationship. The other side, with @JoinColumn, owns the foreign key.
Why should I avoid returning entities from REST controllers?
Lazy fields can throw exceptions, bidirectional relations cause infinite JSON recursion, and you expose internal structure. Use DTOs.
What is open-in-view?
A Spring Boot default that keeps the JPA session open until the response is written. It hides lazy loading problems and holds connections longer, so many teams switch it off.
Why is my child row not saved when I add it to the parent's list?
Cascade PERSIST or ALL is missing, or you changed only the inverse side and the owning side (the @ManyToOne) was not set.
Should entities have equals and hashCode based on all fields?
No. Fields change and lazy fields trigger loading. Use the id (with a constant hashCode) or a stable business key.