Here is the English translation of the article body.
Are you working with JPA in Spring Boot and unsure how to express relationships between tables in your entity classes?
This article walks through relationship mapping step by step, starting with the basics of the @OneToMany, @ManyToOne, and @ManyToMany annotations, then covering when to choose bidirectional versus unidirectional associations, cascade settings, and how to pick a FetchType. It also covers countermeasures for pitfalls you commonly run into in practice, such as the N+1 problem and circular references.
What Is Relationship Mapping in JPA
Relationship mapping in JPA (Java Persistence API) is the mechanism for expressing relationships between database tables in Java entity classes. Relationships that a relational database represents with foreign keys can be handled in an object-oriented way with JPA.
The main kinds of relationships:
- One-to-Many: One entity holds multiple related entities (for example, one user has many posts)
- Many-to-One: Multiple entities reference a single related entity (for example, many posts belong to one user)
- Many-to-Many: Multiple entities have multiple relationships with each other (for example, students and courses)
- One-to-One: One entity holds exactly one related entity (for example, a user and a profile)
The corresponding annotations are @OneToMany, @ManyToOne, @ManyToMany, and @OneToOne. This article focuses on the first three, which are the most frequently used.
The Basics of @ManyToOne and @OneToMany - Unidirectional Associations
Let’s start with one-to-many and many-to-one, the most commonly used relationships.
What Is @ManyToOne - The Annotation That Puts the Foreign Key on the Many Side
@ManyToOne is the annotation that expresses a relationship in which multiple entities reference a single related entity. In terms of table design, the foreign key column is created on the table of the side annotated with @ManyToOne.
- Placement: always on a field of the entity on the “many” side
- Default FetchType:
EAGER(explicitly specifyingLAZYis recommended) - Foreign key column name: specified with
@JoinColumn(name = "...")(defaults tofieldName_idwhen omitted) - Nullability control:
@JoinColumn(nullable = false)expresses a required reference
As an example, here is how you write the case where multiple posts (Post) belong to one 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;
}
With this implementation, Post can reference User, but User cannot access Post (unidirectional). When navigation in one direction is sufficient, or when you want to keep coupling between entities low, starting with this unidirectional form is the simplest approach.
What Is @OneToMany - The Annotation for Accessing a Collection from the One Side
@OneToMany expresses a relationship in which one entity holds multiple related entities as a collection. In practice, it is almost always used in a bidirectional association combined with @ManyToOne.
- Placement: on a collection field of the entity on the “one” side
- Default FetchType:
LAZY - When used bidirectionally, always specify
mappedBy(covered below) - The collection type is usually
ListorSet; useSetwhen you want to avoid duplicates
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Post> posts = new ArrayList<>();
Be careful: if you use @OneToMany unidirectionally without specifying either @JoinColumn or mappedBy, a join table is generated automatically. It is usually more appropriate to use a unidirectional @ManyToOne or a bidirectional association.
Basic Attributes of @JoinColumn - name/referencedColumnName/nullable/unique
@JoinColumn is the annotation that controls the details of the foreign key column. The commonly used attributes are as follows.
name: the physical name of the foreign key column (defaults tofieldName_primaryKeyNamewhen omitted)referencedColumnName: the column name in the referenced table (defaults to the primary key when omitted)nullable: whether the foreign key allows NULL (falsefor a required reference)unique: whether to add a UNIQUE constraint to the foreign key (behaves like@OneToOne)insertable/updatable: whether to include the column in INSERT/UPDATE statements (used for composite keys or read-only associations)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(
name = "user_id",
referencedColumnName = "id",
nullable = false,
updatable = false
)
private User user;
Things work even if you omit @JoinColumn, but writing it out explicitly makes the schema intent clear.
Bidirectional Associations and the mappedBy Attribute
In practice, bidirectional associations that can be accessed from both sides are used frequently.
What Is mappedBy - The Attribute That Indicates the Owner of the Association
mappedBy is the attribute that tells JPA which entity is the “owner” of a bidirectional association. The side that physically holds the foreign key (the @ManyToOne side) is the owner, and mappedBy is specified on the non-owning side (the inverse side).
- Which side specifies it: the
@OneToManyside (in a bidirectional association) - What value to specify: the name of the field in the owning entity that references this entity
- Effect of omitting it: JPA treats the two as independent associations and generates an unintended join table
- Because foreign key updates are only reflected from the owning side, use helper methods to keep both sides in sync
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<>();
// ヘルパーメソッド
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 "user" in mappedBy = "user" points to the user field of the owning Post class. Since the foreign key is not updated unless user is set on the Post side, the standard practice is to maintain consistency on both sides at once with a helper method like addPost.
Many-to-Many Relationship Mapping with @ManyToMany
Many-to-many relationships are represented in the database with a join table, but in JPA they can be written concisely with @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側
@Entity
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToMany(mappedBy = "courses") // 双方向の場合
private Set<Student> students = new HashSet<>();
}
@JoinTable lets you customize the name and column names of the join table. joinColumns specifies the foreign key for your own side, and inverseJoinColumns specifies the foreign key for the other side. In many-to-many relationships, Set is often used instead of List to avoid duplicates.
Practical Considerations for Many-to-Many Relationships
If you want the join table to carry additional attributes (such as a registration timestamp or a status), @ManyToMany cannot handle it. In that case, create the join table as an independent entity and break it down into two @ManyToOne associations. Conversely, if you do not need extra attributes, @ManyToMany is sufficient.
@Entity
public class Enrollment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
private Student student;
@ManyToOne
private Course course;
private LocalDateTime enrolledAt; // 追加属性
private String status; // 追加属性
}
CascadeType - Propagating Operations to Related Entities
CascadeType controls whether operations on a parent entity are propagated to its related entities.
Types of CascadeType
- PERSIST: When the parent is saved (persisted), related entities are saved as well
- MERGE: When the parent is merged, related entities are merged as well
- REMOVE: When the parent is deleted, related entities are deleted as well
- REFRESH: When the parent is refreshed, related entities are refreshed as well
- DETACH: When the parent is detached, related entities are detached as well
- ALL: Propagates all of the operations above
Example of Cascade Behavior
@OneToMany(mappedBy = "user", cascade = CascadeType.PERSIST)
private List<Post> posts = new ArrayList<>();
// 使用例
User user = new User("太郎", "[email protected]");
Post post = new Post("タイトル", "本文");
user.addPost(post);
entityManager.persist(user); // userとpostの両方が保存される
Best Practices for Cascade Settings
CascadeType.ALL looks convenient, but because it includes REMOVE, there is a risk of unintentionally deleting children when the parent is deleted. Explicitly specify only the operations you need, such as PERSIST and MERGE.
// 推奨される設定例
@OneToMany(mappedBy = "user", cascade = {CascadeType.PERSIST, CascadeType.MERGE})
private List<Post> posts = new ArrayList<>();
How It Differs from the orphanRemoval Attribute
@OneToMany(mappedBy = "user", orphanRemoval = true)
private List<Post> posts = new ArrayList<>();
// orphanRemoval = trueの場合、リストから削除するだけで子エンティティが削除される
user.removePost(post);
userRepository.save(user); // postがDBからも削除される
orphanRemoval = true automatically deletes child entities (orphans) whose association with the parent entity has been severed. Unlike CascadeType.REMOVE, the child is deleted simply by removing it from the list, without deleting the parent.
FetchType - Choosing a Data Fetching Strategy
FetchType controls when related entities are fetched.
// LAZY: 実際にアクセスされるまで取得されない
@ManyToOne(fetch = FetchType.LAZY)
private User user;
// EAGER: 親エンティティと同時に取得される
@ManyToOne(fetch = FetchType.EAGER)
private User user;
With LAZY, SQL is issued only when the related entity is first accessed, so you fetch only the data you need. With EAGER, the association is fetched together with the parent entity, which may load data you do not need and can also cause the N+1 problem.
The default differs per annotation.
@ManyToOne,@OneToOne: EAGER (default)@OneToMany,@ManyToMany: LAZY (default)
As a rule, the safe policy is to explicitly specify FetchType.LAZY and control the fetching strategy only where needed with @EntityGraph or JOIN FETCH, described later.
Note that when using FetchType.LAZY, accessing a related entity outside the session raises a LazyInitializationException. Countermeasures include (1) accessing it within a transaction, (2) fetching it explicitly with @EntityGraph or JOIN FETCH, and (3) using a DTO to retrieve the necessary data within the session.
The N+1 Problem and How to Address It
The N+1 problem is a performance issue you frequently encounter with JPA.
What Is the N+1 Problem
List<Post> posts = postRepository.findAll();
for (Post post : posts) {
System.out.println(post.getUser().getName()); // 各postごとにSQLが発行される!
}
In this code, one SQL query is executed to fetch all posts, and then another SQL query is executed N times (once per post) to fetch each post’s user. A total of N+1 SQL queries are issued, degrading performance.
Solving It with @EntityGraph
The easiest approach is to annotate the repository method with @EntityGraph so that related entities are fetched 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);
}
Specify the field names of the associations you want to fetch in attributePaths. You can specify multiple associations at once, and a LEFT OUTER JOIN is used internally.
Solving It with JOIN FETCH in JPQL
When dealing with complex conditions or multiple associations, using JOIN FETCH in JPQL (Java Persistence Query Language) gives you flexible control.
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();
}
When fetching multiple collections, DISTINCT is required to eliminate duplicate rows caused by the Cartesian product. Using LEFT JOIN FETCH lets you retrieve the parent entity even when the association is null.
Note that even after resolving N+1, you can still get stuck waiting for database connections. Connection pool configuration is covered in How to Properly Configure and Tune the HikariCP Connection Pool in Spring Boot.
The Circular Reference Problem in Bidirectional Associations and How to Fix It
When converting entities to JSON in a REST API, bidirectional associations cause circular reference errors.
Cause of Circular Reference Errors
// UserエンティティがPostのリストを持ち
// PostエンティティがUserを持つ双方向関連の場合
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
return userRepository.findById(id).orElseThrow();
}
// JSON変換時:
// User -> Posts -> User -> Posts -> ... (無限ループ)
Since circular references usually surface as exceptions in the end, unifying exception handling across the entire REST API makes it easier to identify the cause. For implementation patterns, see How to Implement Exception Handling in Spring Boot REST APIs.
Solving It with @JsonIgnore
You can add @JsonIgnore to one side of the association to exclude it from JSON conversion, or use the @JsonManagedReference and @JsonBackReference pair.
@Entity
public class User {
@OneToMany(mappedBy = "user")
@JsonManagedReference // 親側
private List<Post> posts = new ArrayList<>();
}
@Entity
public class Post {
@ManyToOne
@JoinColumn(name = "user_id")
@JsonBackReference // 子側(シリアライズ時に無視される)
private User user;
}
The Fundamental Solution with the DTO Pattern (Recommended)
The most recommended approach is to use a DTO (Data Transfer Object) instead of returning entities directly.
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); // エンティティをDTOに変換
}
With the DTO pattern, circular references do not occur, you have full control over the shape of the API response, and security risk is reduced because the internal structure of your entities is not exposed to clients. In a Controller that returns DTOs, combining @Valid on the input side keeps both requests and responses consistent. Usage is covered in How to Implement Validation Simply with the Spring Boot @Valid Annotation.
Practical Example: A Complete Implementation of User and Post
Here is a practical sample that brings together 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(); // N+1問題を回避
}
// 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でpostも保存される
}
}
Summary and Best Practices
Finally, here are guidelines you can apply in practice.
- Explicitly specify LAZY as the default FetchType
- Keep cascade to the necessary minimum (be cautious with
ALLandREMOVE) - Always set mappedBy in bidirectional associations (specify the field name on the owning side)
- Address the N+1 problem with
@EntityGraphorJOIN FETCH - Use DTOs in REST APIs instead of returning entities directly
Starting with a simple design and addressing performance problems only once they actually occur helps you avoid the complexity of premature optimization. Try out the patterns introduced in this article in real code to deepen your understanding.
Related Articles
- How to Implement Exception Handling in Spring Boot REST APIs - Unified exception handling patterns, including exceptions raised by JPA
- Dependency Injection (DI) - For understanding the Repository/Service structure
- How to Properly Configure and Tune the HikariCP Connection Pool in Spring Boot - Database connection settings to combine with JPA
- How to Write Controller Unit Tests with MockMvc in Spring Boot - How to write tests that mock the Repository layer
- How to Loosely Couple Modules with ApplicationEvent in Spring Boot - Designing propagation of entity change events