Entity Relationships
Reviewed & published by Brayan K
Real data is connected. JPA maps those connections with @OneToMany, @ManyToOne, and @ManyToMany — plus @JoinColumn, fetch types, and cascading to control how the links behave.
Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
Before You Start
Make sure you are comfortable mapping a single JPA entity with @Entity, @Id, and @Column before connecting entities together.
What You'll Learn
- Modeling one-to-many and many-to-one
- The owning side vs the inverse side (mappedBy)
- Naming foreign keys with @JoinColumn
- Many-to-many with @JoinTable
- EAGER vs LAZY fetch types
- Cascading and orphanRemoval
The Big Idea — Lines Drawn Between Tables
💡 Analogy: Picture each table as a box and a relationship as a line drawn between boxes. A many-to-one line means many child rows each point at one parent — like many order lines pointing at one order; the child carries the @JoinColumn "pointer." The one-to-many view is just looking down the same line from the parent. A many-to-many needs a little switchboard table in the middle because neither side can hold a single pointer.
Only one end of each line is "in charge" of writing it — that is the owning side.
1️⃣ One-to-Many / Many-to-One
The @ManyToOne child owns the foreign key; the @OneToMany parent uses mappedBy to point back. Keep both sides in sync in a helper method so the in-memory graph matches the database.
import jakarta.persistence.*;
import java.util.*;
// A bidirectional one-to-many: one Order has many OrderItems.
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// INVERSE side: mappedBy points to the "order" field on OrderItem.
// cascade ALL + orphanRemoval ties the items' lifecycle to the order.
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
protected Order() { }
public void addItem(OrderItem item) {
items.add(item);
item.setOrder(this); // keep both sides in sync
}
public List<OrderItem> getItems() { return items; }
}
@Entity
@Table(name = "order_items")
class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// OWNING side: this entity holds the foreign key "order_id".
// @ManyToOne defaults to EAGER; set LAZY to defer loading the parent.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;
private String product;
protected OrderItem() { }
public OrderItem(String product) { this.product = product; }
public void setOrder(Order order) { this.order = order; }
public Order getOrder() { return order; }
}2️⃣ Cascading & Orphan Removal — the idea
cascade propagates operations from parent to child; orphanRemoval deletes a child removed from the collection. Here is a plain-Java sketch of that ownership you can run.
import java.util.*;
// PLAIN-JAVA model of a one-to-many graph and "cascade delete".
// Removing the parent removes its children (like orphanRemoval/cascade).
public class Main {
static class Item {
String name;
Item(String name) { this.name = name; }
}
static class Order {
int id;
List<Item> items = new ArrayList<>();
Order(int id) { this.id = id; }
void add(Item i) { items.add(i); }
}
public static void main(String[] args) {
Order o = new Order(1);
o.add(new Item("Keyboard"));
o.add(new Item("Mouse"));
System.out.println("order " + o.id + " has " + o.items.size() + " items");
for (Item i : o.items) System.out.println(" - " + i.name);
// remove a child from the collection (an "orphan")
o.items.remove(0);
System.out.println("after orphan removal: " + o.items.size() + " item(s)");
}
}3️⃣ Many-to-Many with a Join Table
When both sides have "many," neither row can hold a single pointer, so JPA uses a join table. The owning side declares it with @JoinTable; the inverse side just uses mappedBy.
@ManyToMany
@JoinTable(name = "enrollment",
joinColumns = @JoinColumn(name = "student_id"),
inverseJoinColumns = @JoinColumn(name = "course_id"))import jakarta.persistence.*;
import java.util.*;
// A many-to-many: Students enroll in many Courses, Courses have many Students.
@Entity
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
// OWNING side defines the join table linking the two primary keys.
@ManyToMany
@JoinTable(
name = "enrollment",
joinColumns = @JoinColumn(name = "student_id"),
inverseJoinColumns = @JoinColumn(name = "course_id"))
private Set<Course> courses = new HashSet<>();
protected Student() { }
}
@Entity
class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
// INVERSE side just refers back via mappedBy — no second join table.
@ManyToMany(mappedBy = "courses")
private Set<Student> students = new HashSet<>();
protected Course() { }
}🧠 Quick Recall
Predict before you peek.
Answer: the @ManyToOne (child) side — it holds the @JoinColumn. The @OneToMany side is the inverse via mappedBy.
Answer: LAZY. To-many collections load only when accessed; to-one defaults to EAGER.
Answer: neither side can store a single foreign key for "many," so a separate link table holds pairs of keys.
🎯 YOUR TURN — Posts and Comments
Model a bidirectional one-to-many: one Post has many Comments, with the comment owning the foreign key.
import jakarta.persistence.*;
import java.util.*;
// YOUR TURN: model a blog.
// - Post (one) has many Comment (many) -> bidirectional one-to-many
// - Comment owns the foreign key "post_id" via @ManyToOne + @JoinColumn
// - On Post, use @OneToMany(mappedBy = "post", cascade = CascadeType.ALL)
// - Make the @ManyToOne LAZY
//
// @Entity class Post { ... List<Comment> comments ... }
// @Entity class Comment { ... @ManyToOne Post post ... }🧩 MINI-CHALLENGE — Authors and Books
Model a many-to-many between Author and Book with a book_author join table.
import jakarta.persistence.*;
import java.util.*;
// MINI-CHALLENGE: model authors and books as MANY-TO-MANY.
// - An Author writes many Books; a Book has many Authors.
// - Pick ONE owning side and define @JoinTable there
// (name "book_author", joinColumns + inverseJoinColumns).
// - The other side uses @ManyToMany(mappedBy = "...").
//
// Think: which side owns the join table? what does mappedBy point to?Common Errors
- ❌ Updating only the inverse side. The foreign key won't change. Fix: set the owning (@ManyToOne) side too, ideally via a sync helper.
- ❌ Leaving @ManyToOne EAGER everywhere. It pulls huge graphs. Fix: use fetch = FetchType.LAZY and fetch joins when needed.
- ❌ Cascading REMOVE across many-to-many. It deletes shared rows. Fix: cascade nothing (or only persist/merge) there.
- ❌ Forgetting mappedBy. JPA creates two separate mappings (and a stray join table). Fix: mark the inverse side with mappedBy.
- ❌ Using List for @ManyToMany. Duplicates and ordering surprises. Fix: prefer a Set.
- ❌ N+1 queries. Lazy collections fetched in a loop. Fix: use a JPQL fetch join or an entity graph.
Pro Tips
- 💡 Add helper methods (addItem) that set both sides so your object graph stays consistent.
- 💡 Default to LAZY and explicitly fetch what a use-case needs.
- 💡 Use cascade + orphanRemoval only for true ownership like order to line items.
- 💡 Pick a single owning side per relationship to avoid duplicate writes.
📋 Quick Reference
| Concept | Annotation / idea | Note |
|---|---|---|
| many-to-one | @ManyToOne | Owns the FK; EAGER default |
| one-to-many | @OneToMany(mappedBy) | Inverse; LAZY default |
| many-to-many | @ManyToMany + @JoinTable | Needs link table |
| foreign key | @JoinColumn(name) | On the owning side |
| inverse marker | mappedBy = "field" | Points to owner |
| eager | FetchType.EAGER | Default to-one |
| lazy | FetchType.LAZY | Default to-many |
| cascade | cascade = CascadeType.ALL | Propagate ops |
| orphan removal | orphanRemoval = true | Delete detached child |
| join table | @JoinTable(joinColumns,...) | For many-to-many |
📚 Resources / Keep Learning
- 📘 Jakarta Persistence 3.1 specification (jakarta.ee)
- 📗 Hibernate User Guide — associations (docs.jboss.org)
- 📗 Spring Data JPA reference (docs.spring.io)
🎉 Lesson Complete!
You can now connect entities with @OneToMany, @ManyToOne, and @ManyToMany, name foreign keys with @JoinColumn, choose the owning side with mappedBy, tune EAGER vs LAZY fetching, and manage child lifecycles with cascade and orphanRemoval.
Practice quiz
Which annotation models the 'many' side that owns a foreign key column?
- @ManyToOne
- @OneToMany
- @OneToOne
- @ManyToMany
Answer: @ManyToOne. @ManyToOne sits on the child entity and, by default, owns the foreign key column referencing the parent.
In a bidirectional one-to-many, which side is typically the inverse (non-owning) side?
- The @ManyToOne side
- The @OneToMany side
- Both sides equally
- Neither side
Answer: The @OneToMany side. The @OneToMany collection side uses mappedBy and is the inverse side; the @ManyToOne side owns the foreign key.
What does @JoinColumn specify?
- The primary key column
- A unique index
- The fetch strategy
- The foreign key column name used for the association
Answer: The foreign key column name used for the association. @JoinColumn names the foreign key column that links the two tables.
What is the DEFAULT fetch type for @ManyToOne?
- LAZY
- EAGER
- ASYNC
- There is no default
Answer: EAGER. To-one associations (@ManyToOne, @OneToOne) default to EAGER fetching.
What is the DEFAULT fetch type for @OneToMany?
- EAGER
- BATCH
- LAZY
- JOIN
Answer: LAZY. To-many associations (@OneToMany, @ManyToMany) default to LAZY fetching.
What does the mappedBy attribute indicate?
- The owning field on the other entity
- The join table name
- The cascade type
- The fetch mode
Answer: The owning field on the other entity. mappedBy points to the field on the owning side, marking this side as the inverse (non-owning) side.
Which relationship requires a join table by default?
- @ManyToOne
- @OneToOne
- @ManyToMany
- @OneToMany unidirectional with @JoinColumn
Answer: @ManyToMany. @ManyToMany cannot be expressed with a single foreign key, so it uses a separate join table.
What does cascade = CascadeType.ALL do on a parent's collection?
- Deletes the database
- Propagates persist, merge, remove, etc. to the children
- Disables lazy loading
- Makes the relationship read-only
Answer: Propagates persist, merge, remove, etc. to the children. CascadeType.ALL propagates all entity operations (persist, merge, remove, refresh, detach) from parent to children.
What problem does orphanRemoval = true solve?
- N+1 queries
- Eager fetching
- Unique constraints
- Deleting a child removed from the parent's collection
Answer: Deleting a child removed from the parent's collection. orphanRemoval deletes a child row once it is removed from the owning collection and no longer referenced.
Why is LAZY often preferred for associations?
- It always loads everything immediately
- It avoids loading related data until it is actually used
- It removes foreign keys
- It is required by JPA
Answer: It avoids loading related data until it is actually used. LAZY fetching defers loading related entities until accessed, avoiding unnecessary queries and large result sets.
Continue this course
- Previous: JPA Entities & Mapping
- Next: Derived Queries & @Query — findByX query methods, @Query JPQL, paging and sorting
- Quick reference: Java cheat sheet
Frequently asked questions
What is the difference between the owning side and the inverse side?
In a bidirectional relationship, exactly one side owns the foreign key (or join table) — that is the side JPA reads and writes to update the database link. The other, inverse side uses mappedBy to point at the owning field and is read-only for the purpose of the link. In a one-to-many/many-to-one pair, the @ManyToOne side owns the foreign key column, and the @OneToMany side is the inverse. Updating only the inverse side will not change the foreign key, which is a common source of bugs.
When does each association default to EAGER versus LAZY fetching?
To-one associations — @ManyToOne and @OneToOne — default to EAGER, meaning the related entity is loaded immediately with its owner. To-many associations — @OneToMany and @ManyToMany — default to LAZY, so the collection is loaded only when you first access it. EAGER fetching is convenient but can pull in large object graphs and cause performance problems, so many teams set @ManyToOne(fetch = FetchType.LAZY) explicitly and load what they need with fetch joins or entity graphs.
What does @JoinColumn do, and where does it go?
@JoinColumn names the foreign key column that implements an association and goes on the owning side. On a @ManyToOne it specifies the column in the child table that references the parent's primary key, for example @JoinColumn(name = "order_id"). For @ManyToMany you instead use @JoinTable, which names the link table and its two join columns. If you omit @JoinColumn, JPA derives a default column name from the association, but naming it explicitly keeps your schema predictable.
What is cascading and when should I use CascadeType.ALL?
Cascading propagates entity operations from a parent to its associated children. With cascade = CascadeType.ALL on an order's items, persisting the order also persists its items, and removing the order removes them. It is appropriate when the children are owned by and meaningless without the parent — like order line items. It is dangerous for shared references, such as a many-to-many between students and courses, where removing one student should not delete shared courses; there you usually cascade nothing or only persist/merge.
What does orphanRemoval do, and how is it different from cascade remove?
orphanRemoval = true deletes a child as soon as it is removed from the parent's collection, even if the parent itself is not deleted. Cascade remove only deletes children when the parent is deleted. So if you call order.getItems().remove(item), orphanRemoval will delete that item's row on flush, treating it as an orphan; cascade remove alone would leave it with a dangling or null foreign key. Use orphanRemoval for true parent-child ownership where a detached child should not survive.