Are you struggling with how to represent table relationships in entity classes when working with JPA in Spring Boot?
This article walks you through relationship mapping using @OneToMany, @ManyToOne, and @ManyToMany annotations — from the basics, to choosing between bidirectional and unidirectional associations, configuring cascade settings, and selecting the right FetchType. It also covers practical pitfalls you’ll commonly encounter, such as the N+1 problem and circular references.
What Is JPA Relationship Mapping?
JPA (Java Persistence API) relationship mapping is the mechanism for representing database table relationships in Java entity classes.
In relational databases, relationships between tables are expressed using foreign keys. JPA lets you handle these relationships in an object-oriented way.
Main relationship types:
- One-to-Many: One entity holds multiple related entities (e.g., one user has many posts)
- Many-to-One: Multiple entities reference a single related entity (e.g., many posts belong to one user)
- Many-to-Many: Multiple entities have multiple relationships with each other (e.g., students and courses)
- One-to-One: One entity holds exactly one related entity (e.g., a user and their profile)
Benefits of relationship mapping:
- Enables object-oriented data access
- Lets you work with relationships without writing raw SQL
- Improves code readability and maintainability
The primary JPA annotations are @OneToMany, @ManyToOne, @ManyToMany, and @OneToOne. This article focuses on the first three, which are most commonly used.
Basics of @ManyToOne and @OneToMany — Unidirectional Associations
Let’s start with the most common One-to-Many relationship, beginning with unidirectional associations.
Unidirectional @ManyToOne
A Many-to-One association is expressed with the @ManyToOne annotation. Consider the case where multiple posts (Post) belong to a single user (User).
@Entity
@Table(name = "posts")
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String content;
@ManyToOne
@JoinColumn(name = "user_id")
private User user;
}
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
}
Key points:
@ManyToOneis placed on the “many” side (Post)@JoinColumnlets you specify the foreign key column name (if omitted,user_idis generated automatically)- With this implementation, you can navigate from Post to User, but not from User to Post (unidirectional)
When to Use Unidirectional Associations
Use unidirectional associations when navigation in only one direction is sufficient. They are appropriate when you want to keep coupling between entities low, or when you prefer a simpler design.
Note: With a unidirectional @OneToMany, if you don’t specify @JoinColumn, a join table is automatically generated. It’s generally better to use unidirectional @ManyToOne or a bidirectional association instead.
Bidirectional Associations and the mappedBy Attribute
In practice, bidirectional associations that allow access from both sides are commonly used.
Implementing Bidirectional @OneToMany/@ManyToOne
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
@OneToMany(mappedBy = "user")
private List<Post> posts = new ArrayList<>();
// Helper method
public void addPost(Post post) {
posts.add(post);
post.setUser(this);
}
}
@Entity
@Table(name = "posts")
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String content;
@ManyToOne
@JoinColumn(name = "user_id")
private User user;
}
The Role of the mappedBy Attribute
The mappedBy attribute indicates which side “owns” the relationship.
- Owning side: The side with
@ManyToOne(Post) — the side that holds the foreign key - Inverse side: The side with
@OneToMany(mappedBy = "user")(User)
The value of mappedBy is the field name on the owning side (the user field in the Post class).
Important: Without mappedBy, JPA treats the two sides as independent relationships and produces an unintended table structure.
Helper Methods for Maintaining Consistency
With bidirectional associations, it is recommended to provide helper methods like addPost to keep both sides consistent. Using this method ensures that the item is added to the User’s list and the Post’s reference is set simultaneously, so both sides of the association are always in sync.
Many-to-Many Relationship Mapping with @ManyToMany
Many-to-Many relationships are represented in databases using a join table, but JPA lets you express them concisely with @ManyToMany.
Unidirectional and Bidirectional @ManyToMany
Let’s look at an example with students (Student) and courses (Course).
@Entity
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToMany
@JoinTable(
name = "student_course",
joinColumns = @JoinColumn(name = "student_id"),
inverseJoinColumns = @JoinColumn(name = "course_id")
)
private Set<Course> courses = new HashSet<>();
}
// Course side for a bidirectional association
@Entity
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToMany(mappedBy = "courses") // for bidirectional
private Set<Student> students = new HashSet<>();
}
Key points:
@JoinTablelets you customize the join table name and column namesjoinColumnsspecifies your side’s foreign key;inverseJoinColumnsspecifies the other side’sSetis preferred overListfor Many-to-Many to avoid duplicates
Practical Considerations for Many-to-Many Associations
If you need to store additional attributes on the join table (e.g., enrollment date, status), @ManyToMany cannot accommodate this.
In that case, you need to model the join table as a standalone entity and decompose it into two @ManyToOne associations.
@Entity
public class Enrollment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
private Student student;
@ManyToOne
private Course course;
private LocalDateTime enrolledAt; // additional attribute
private String status; // additional attribute
}
CascadeType — Propagating Operations to Related Entities
CascadeType controls whether operations on a parent entity are propagated to its related entities.
CascadeType Options
The available CascadeType values are:
- PERSIST: Saving (persisting) the parent also saves the related entities
- MERGE: Merging the parent also merges the related entities
- REMOVE: Deleting the parent also deletes the related entities
- REFRESH: Refreshing the parent also refreshes the related entities
- DETACH: Detaching the parent also detaches the related entities
- ALL: Propagates all of the above operations
Cascade Behavior Example
@OneToMany(mappedBy = "user", cascade = CascadeType.PERSIST)
private List<Post> posts = new ArrayList<>();
// Usage example
User user = new User("Taro", "[email protected]");
Post post = new Post("Title", "Content");
user.addPost(post);
entityManager.persist(user); // both user and post are saved
With PERSIST, related entities are saved together when the parent entity is saved. With REMOVE, deletions are propagated — be careful of unintended data loss.
Best Practices for Cascade Configuration
CascadeType.ALL may seem convenient, but it can cause unintended deletions and other side effects. It is recommended to explicitly specify only the operations you need.
// Recommended configuration
@OneToMany(mappedBy = "user", cascade = {CascadeType.PERSIST, CascadeType.MERGE})
private List<Post> posts = new ArrayList<>();
Difference from the orphanRemoval Attribute
@OneToMany(mappedBy = "user", orphanRemoval = true)
private List<Post> posts = new ArrayList<>();
// With orphanRemoval = true, removing from the list deletes the child entity
user.removePost(post);
userRepository.save(user); // post is also deleted from the DB
orphanRemoval = true automatically deletes child entities (orphans) that are no longer associated with the parent. Unlike CascadeType.REMOVE, simply removing the child from the list is enough to trigger deletion.
FetchType — Choosing a Data Fetching Strategy
FetchType controls when related entities are loaded.
FetchType.LAZY vs. EAGER
// LAZY: not fetched until actually accessed
@ManyToOne(fetch = FetchType.LAZY)
private User user;
// EAGER: fetched together with the parent entity
@ManyToOne(fetch = FetchType.EAGER)
private User user;
LAZY issues the SQL only when the related entity is accessed, which is more memory-efficient and fetches only the data you need. EAGER fetches the related entity at the same time as the parent, which is convenient when the data is always needed, but may load unnecessary data.
Default FetchType
The default varies by annotation:
@ManyToOne,@OneToOne: EAGER (default)@OneToMany,@ManyToMany: LAZY (default)
General Principle for Choosing FetchType
It is generally recommended to explicitly specify FetchType.LAZY and control the fetching strategy at the query level as needed. This makes performance optimization easier.
Handling LazyInitializationException
When using FetchType.LAZY, accessing a related entity outside of a session will throw a LazyInitializationException. Solutions include: (1) access within a transaction, (2) explicitly fetch using @EntityGraph or JOIN FETCH, or (3) use DTOs to retrieve the necessary data within the session.
The N+1 Problem and How to Address It
The N+1 problem is a common performance issue in JPA.
What Is the N+1 Problem?
List<Post> posts = postRepository.findAll();
for (Post post : posts) {
System.out.println(post.getUser().getName()); // a SQL query is issued for each post!
}
With FetchType.LAZY, this code executes one SQL query to fetch all posts, then N additional queries (one per post) to fetch each post’s user. This results in N+1 total SQL queries and degrades performance.
Solving It with @EntityGraph
The @EntityGraph annotation lets you fetch related entities in a single query.
public interface PostRepository extends JpaRepository<Post, Long> {
@EntityGraph(attributePaths = "user")
List<Post> findAll();
@EntityGraph(attributePaths = {"user", "comments"})
List<Post> findByTitleContaining(String title);
}
Key points:
- Specify the field name of the related entity you want to fetch in
attributePaths - You can fetch multiple associations at once
- Internally uses a LEFT OUTER JOIN
Solving It with JPQL JOIN FETCH
For more fine-grained control, use JOIN FETCH in JPQL (Java Persistence Query Language).
public interface PostRepository extends JpaRepository<Post, Long> {
@Query("SELECT p FROM Post p JOIN FETCH p.user")
List<Post> findAllWithUser();
@Query("SELECT DISTINCT p FROM Post p " +
"LEFT JOIN FETCH p.user " +
"LEFT JOIN FETCH p.comments")
List<Post> findAllWithUserAndComments();
}
Key points:
- Use
JOIN FETCHto explicitly fetch related entities DISTINCTis required when fetching multiple collections (to eliminate duplicate rows from the Cartesian product)LEFT JOIN FETCHensures the parent entity is still returned even when the association is null
When to Use Each Approach
For simple association fetching, @EntityGraph is concise and convenient. For complex conditions or multiple associations, JPQL’s JOIN FETCH offers more flexible control.
Circular Reference Issues in Bidirectional Associations
When serializing entities to JSON in a REST API, bidirectional associations can cause circular reference errors.
The Cause of Circular Reference Errors
// When User has a list of Posts
// and Post has a User reference (bidirectional)
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
return userRepository.findById(id).orElseThrow();
}
// During JSON serialization:
// User -> Posts -> User -> Posts -> ... (infinite loop)
Resolving with @JsonIgnore
One approach is to add @JsonIgnore to one side of the association to exclude it from JSON serialization. Alternatively, use the @JsonManagedReference and @JsonBackReference pair.
@Entity
public class User {
@OneToMany(mappedBy = "user")
@JsonManagedReference // parent side
private List<Post> posts = new ArrayList<>();
}
@Entity
public class Post {
@ManyToOne
@JoinColumn(name = "user_id")
@JsonBackReference // child side (ignored during serialization)
private User user;
}
The Recommended Solution: The DTO Pattern
The most recommended approach is to avoid returning entities directly and instead use DTOs (Data Transfer Objects).
public class UserResponse {
private Long id;
private String name;
private List<PostSummary> posts;
}
@GetMapping("/users/{id}")
public UserResponse getUser(@PathVariable Long id) {
User user = userRepository.findById(id).orElseThrow();
return convertToDto(user); // convert entity to DTO
}
The DTO pattern eliminates circular reference issues, gives you full control over the API response format, and reduces security risks by not exposing the entity’s internal structure to API clients.
Practical Example: A Complete User and Post Implementation
Let’s look at a practical sample that integrates everything covered so far.
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false, unique = true)
private String email;
@OneToMany(mappedBy = "user", cascade = {CascadeType.PERSIST, CascadeType.MERGE})
private List<Post> posts = new ArrayList<>();
public void addPost(Post post) {
posts.add(post);
post.setUser(this);
}
}
@Entity
@Table(name = "posts")
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
}
// Repository
public interface PostRepository extends JpaRepository<Post, Long> {
@Query("SELECT p FROM Post p JOIN FETCH p.user")
List<Post> findAllWithUser(); // avoids the N+1 problem
}
// Service
@Service
@Transactional
public class BlogService {
public User createUserWithPost(String name, String email, String postTitle) {
User user = new User(name, email);
Post post = new Post(postTitle);
user.addPost(post);
return userRepository.save(user); // cascade saves post as well
}
}
By combining @OneToMany, @ManyToOne, cascade settings, FetchType, and N+1 mitigations as shown here, you can build practical, well-designed entities.
Summary and Best Practices
Here is a summary of practical guidelines for JPA relationship mapping.
Recommended Settings
- Default to FetchType.LAZY — explicitly specify
LAZYfor performance optimization - Keep cascade to the minimum necessary — use
CascadeType.ALLandREMOVEwith care - Always set mappedBy for bidirectional associations — specify the field name on the owning side
- Be mindful of the N+1 problem — use
@EntityGraphorJOIN FETCHto fetch data efficiently - Don’t return entities directly — use DTOs in REST APIs to fundamentally avoid circular references
General Design Principles
Prioritize simplicity and start with straightforward designs. It is better to address performance issues when they actually arise, rather than adding complexity through premature optimization. Favor correctness of business logic over technical optimization.
JPA relationship mapping may feel complex at first, but once you understand the fundamental patterns, you can apply them effectively in real-world projects. Try the examples from this article in actual code to gradually deepen your understanding.
For related reading, check out How to Implement Exception Handling in Spring Boot REST APIs and Dependency Injection (DI) to get even more out of the framework.