Spring Boot Tutorial
JPA Entity Relationships in Depth
Almost every domain model has relationships: a customer has orders, an order has line items, a student enrols in many courses and a course has many students. JPA lets you map these as object references, but the details — which side owns the foreign key, what cascades, what gets loaded when — decide whether your application is correct and fast or full of surprising bugs.
This lesson covers all four relationship types, owning and inverse sides, bidirectional helper methods, cascading and orphan removal, many-to-many with an explicit join entity, and how to implement equals and hashCode safely for entities.
The Four Relationship Types
JPA supports @OneToOne, @OneToMany, @ManyToOne and @ManyToMany. In a relational database, all of them are implemented with foreign keys, and many-to-many uses a join table. Model the relationship in the direction your code actually navigates; not every relationship needs to be bidirectional.
Owning Side and mappedBy
In a bidirectional relationship, exactly one side owns the foreign key column — only changes to that side are written to the database. For one-to-many/many-to-one, the @ManyToOne side always owns it (it holds @JoinColumn). The other side declares mappedBy naming the owning field. If you only add an item to order.getItems() without setting item.setOrder(order), nothing is saved. Helper methods such as addItem() keep both sides in sync.
Fetch Types
@ManyToOne and @OneToOne default to EAGER fetching, which loads the related entity every time — often more than you need. Collections default to LAZY. A good rule is to make every association FetchType.LAZY and load what you need per use case with fetch joins or entity graphs (see the N+1 lesson).
Cascade and orphanRemoval
cascade = CascadeType.ALL propagates persist, merge, remove and so on from parent to children: saving an order saves its new items. orphanRemoval = true deletes a child when it is removed from the parent's collection. Use both only for true composition — children that cannot exist without their parent (order → line items). Never cascade REMOVE from a many-to-one side (deleting an order must not delete the customer).
Many-to-Many: Prefer a Join Entity
A plain @ManyToMany works when the link has no data of its own. As soon as you need extra columns — enrolment date, progress, role — replace it with an explicit entity (Enrollment) with two @ManyToOne associations. Real systems almost always end up needing that extra data. When you do use @ManyToMany, use a Set, not a List, to avoid inefficient delete-and-reinsert behaviour.
equals and hashCode for Entities
Entities change identity when they are saved (the id goes from null to a value), which breaks HashSet membership if hashCode uses the id naïvely. Either use a natural business key that never changes (like an ISBN or email), or base equality on the id while returning a constant hashCode for the class. Never include lazy associations in equals, hashCode or toString — Lombok's @Data does exactly that and triggers lazy loading or infinite recursion.
Examples
Bidirectional one-to-many with helper methods, cascade and orphan removal
@Entity
@Table(name = "orders")
public class Order {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "customer_id")
private Customer customer;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
public void addItem(OrderItem item) {
items.add(item);
item.setOrder(this); // keep the owning side in sync
}
public void removeItem(OrderItem item) {
items.remove(item);
item.setOrder(null);
}
// getters...
}
@Entity
public class OrderItem {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "order_id") // owning side: holds the foreign key
private Order order;
private String product;
private int quantity;
// constructor, getters, setOrder...
}
// usage
Order order = new Order(customer);
order.addItem(new OrderItem("Spring Boot course", 1));
order.addItem(new OrderItem("Java workbook", 2));
orderRepository.save(order); // cascades to both items
insert into orders (customer_id) values (?)
insert into order_item (order_id, product, quantity) values (?, ?, ?)
insert into order_item (order_id, product, quantity) values (?, ?, ?)
One-to-one with a shared primary key (@MapsId)
@Entity
public class UserProfile {
@Id
private Long id; // same value as the user's id
@OneToOne(fetch = FetchType.LAZY)
@MapsId
@JoinColumn(name = "user_id")
private AppUser user;
private String bio;
private String avatarUrl;
}
create table user_profile (user_id bigint not null primary key, bio varchar(255), avatar_url varchar(255),
foreign key (user_id) references app_users)
Many-to-many replaced by a join entity with extra data
@Entity
@Table(uniqueConstraints = @UniqueConstraint(columnNames = {"student_id", "course_id"}))
public class Enrollment {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
private Student student;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
private Course course;
private LocalDate enrolledOn;
private int progressPercent;
protected Enrollment() {}
public Enrollment(Student student, Course course) {
this.student = student;
this.course = course;
this.enrolledOn = LocalDate.now();
}
}
public interface EnrollmentRepository extends JpaRepository<Enrollment, Long> {
List<Enrollment> findByStudentIdOrderByEnrolledOnDesc(Long studentId);
long countByCourseId(Long courseId);
}
create table enrollment (id bigint generated by default as identity, enrolled_on date, progress_percent integer not null,
course_id bigint not null, student_id bigint not null, primary key (id), unique (student_id, course_id))
Safe equals and hashCode for an entity
@Entity
public class Course {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Course other)) return false;
return id != null && id.equals(other.id); // unsaved entities are only equal to themselves
}
@Override
public int hashCode() {
return Course.class.hashCode(); // constant: stays stable when the id is assigned
}
@Override
public String toString() {
return "Course{id=" + id + ", title='" + title + "'}"; // no lazy associations
}
}
Set<Course> set = new HashSet<>();
set.add(course); // id = null
courseRepository.save(course); // id = 7
set.contains(course) -> true (would be false if hashCode used the id)
Common Mistakes
- Updating only the inverse (mappedBy) side of a relationship and wondering why the foreign key is never saved.
- Leaving @ManyToOne at its default EAGER fetch, loading large object graphs on every query.
- Cascading REMOVE from child to parent, e.g. deleting an order deletes the customer.
- Using Lombok @Data or @EqualsAndHashCode on entities, triggering lazy loading and StackOverflowError through bidirectional toString.
- Using @ManyToMany with a List, which makes Hibernate delete and re-insert all rows on every change.
Key Points to Remember
- The @ManyToOne side owns the foreign key; the other side uses mappedBy.
- Keep both sides of a bidirectional association in sync with helper methods.
- Make associations LAZY and fetch what each use case needs explicitly.
- Use cascade ALL + orphanRemoval only for true parent-child composition.
- Model many-to-many links that carry data as a separate join entity.
Practice the examples
Change an input, predict the result, then compare it with the output. Explain why the result changes.
Use your local JDK or project IDE for these examples. Codelab currently runs Python and HTML/CSS/JavaScript; framework examples may need project dependencies.