Are you having trouble figuring out how to express table relationships in entity classes when working with databases using JPA in Spring Boot?
This article explains association mapping step by step, from the basics of using @OneToMany, @ManyToOne, and @ManyToMany annotations, to choosing between bidirectional and unidirectional relationships, cascade settings, and FetchType selection criteria. With countermeasures for common pitfalls encountered in practice such as the N+1 problem and circular references, you’ll gain practical knowledge you can use immediately.
What is JPA Association Mapping
JPA (Java Persistence API) association mapping is a mechanism for expressing relationships between database tables using Java entity classes.
In relational databases, relationships between tables are expressed using foreign keys, but JPA allows you to handle these in an object-oriented manner.
Main types of associations:
- One-to-Many: One entity has multiple related entities (e.g., one user has multiple posts)
- Many-to-One: Multiple entities reference one related entity (e.g., multiple posts belong to one user)
- Many-to-Many: Multiple entities have multiple associations with each other (e.g., relationships between students and courses)
- One-to-One: One entity has one related entity (e.g., user and profile)
Benefits of using association mapping:
- Enables object-oriented data access
- Allows manipulating relationships without writing SQL directly
- Improves code readability and maintainability
The main annotations provided by JPA are @OneToMany, @ManyToOne, @ManyToMany, and @OneToOne. This article focuses on the first three, which are the most commonly used.
Basics of @ManyToOne and @OneToMany - Unidirectional Associations
Let’s start with the most commonly used one-to-many association. First, we’ll begin with unidirectional associations.
@ManyToOne Unidirectional
Many-to-one associations are expressed using the @ManyToOne annotation. As an example, consider a 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;
}
Key points:
@ManyToOneis placed on the “many” side (Post)- You can specify the foreign key column name with
@JoinColumn(if omitted,user_idis auto-generated) - In this implementation, references from Post to User are possible, but access from User to Post is not (unidirectional)
When to Use Unidirectional Associations
Unidirectional associations are used when navigation from only one direction is sufficient. They are suitable when you want to keep the coupling between entities low or when you prioritize a simple design.
Note: With unidirectional @OneToMany, if you don’t specify @JoinColumn, an intermediate table is auto-generated. Usually, it’s more appropriate to use unidirectional @ManyToOne or bidirectional associations.
Bidirectional Associations and the mappedBy Attribute
In practice, bidirectional associations that allow access from both directions are frequently 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 is the “owner” of the association.
- Owner side: The side with
@ManyToOne(Post) - the side that has the foreign key - Inverse side: The side with
@OneToMany(mappedBy = "user")(User)
The value of mappedBy should be the field name on the owner side (the user field in the Post class).
Important: If you don’t specify mappedBy, JPA recognizes them as two independent associations, resulting in an unintended table structure.
Helper Methods for Maintaining Consistency
In bidirectional associations, it’s recommended to prepare helper methods like addPost to maintain consistency on both sides. By using this method, you simultaneously add to the list on the User side and set the reference on the Post side, ensuring that the association is properly set on both sides.
Many-to-Many Association Mapping with @ManyToMany
Many-to-many associations are expressed in databases using intermediate tables, but in JPA you can describe them concisely with @ManyToMany.
Unidirectional and Bidirectional @ManyToMany
Let’s look at an example of 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 bidirectional
@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:
- You can customize the intermediate table name and column names with
@JoinTable joinColumnsspecifies the foreign key on your own side, andinverseJoinColumnsspecifies the foreign key on the other side- In many-to-many relationships,
Setis often used instead ofListto avoid duplicates
Practical Considerations for Many-to-Many Associations
If you want the intermediate table to have additional attributes (registration date, status, etc.), @ManyToMany cannot handle it.
In that case, you need to create the intermediate table as an independent 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 related entities.
Types of CascadeType
The following types of CascadeType are available.
- PERSIST: When the parent is saved (persist), related entities are also saved
- MERGE: When the parent is merged, related entities are also merged
- REMOVE: When the parent is deleted, related entities are also deleted
- REFRESH: When the parent is refreshed, related entities are also refreshed
- DETACH: When the parent is detached, related entities are also detached
- 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", "taro@example.com");
Post post = new Post("Title", "Body");
user.addPost(post);
entityManager.persist(user); // Both user and post are saved
When PERSIST is specified, related entities are saved together when the parent entity is saved. With REMOVE, deletion is propagated, so be careful of unintended data loss.
Best Practices for Cascade Settings
CascadeType.ALL looks convenient, but it can cause unintended deletions and other issues. It’s recommended to explicitly specify only the operations you need.
// Recommended setting example
@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, the child entity is deleted just by removing it from the list
user.removePost(post);
userRepository.save(user); // post is also deleted from the DB
orphanRemoval = true automatically deletes child entities (orphans) whose association with the parent entity has been severed. Unlike CascadeType.REMOVE, the child entity is deleted simply by removing it from the list.
FetchType - Choosing a Data Retrieval Strategy
FetchType controls when related entities are retrieved.
Difference Between FetchType.LAZY and EAGER
// LAZY: Not retrieved until actually accessed
@ManyToOne(fetch = FetchType.LAZY)
private User user;
// EAGER: Retrieved at the same time as the parent entity
@ManyToOne(fetch = FetchType.EAGER)
private User user;
With LAZY, SQL is only issued when the related entity is first accessed, making it memory-efficient and allowing you to retrieve only the necessary data. With EAGER, the related entity is retrieved at the same time as the parent entity, which is convenient when the data is always needed, but it may retrieve unnecessary data as well.
Default FetchType
The default differs depending on the annotation.
@ManyToOne,@OneToOne: EAGER (default)@OneToMany,@ManyToMany: LAZY (default)
Basic Principles for Choosing FetchType
The recommended approach is to explicitly specify FetchType.LAZY as a base, and control the retrieval strategy at the query level as needed. This makes performance optimization easier.
Countermeasures for LazyInitializationException
When using FetchType.LAZY, accessing related entities outside the session causes a LazyInitializationException. Countermeasures include: (1) accessing within a transaction, (2) explicitly retrieving with @EntityGraph or JOIN FETCH, and (3) using DTOs to retrieve the necessary data within the session.
The N+1 Problem and Its Countermeasures
If you want to comprehensively improve JPA performance, it’s effective to also review the DB connection settings. If the connection pool settings are not appropriate, you may end up stuck waiting for DB connections even after solving N+1. For details, see How to Properly Configure and Tune Spring Boot’s HikariCP Connection Pool.
The N+1 problem is a performance issue frequently encountered in JPA.
What is the N+1 Problem?
List<Post> posts = postRepository.findAll();
for (Post post : posts) {
System.out.println(post.getUser().getName()); // SQL is issued for each post!
}
In this code, if the association is FetchType.LAZY, one SQL is executed to retrieve all posts, and then SQL is executed N times (the number of posts) to retrieve the user for each post. A total of N+1 SQL queries are issued, degrading performance.
Solution Using @EntityGraph
By using the @EntityGraph annotation, you can retrieve 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 names of the related entities you want to retrieve in
attributePaths - You can retrieve multiple associations at the same time
- Internally, LEFT OUTER JOIN is used
Solution Using JPQL’s JOIN FETCH
If you need more flexible control, use JOIN FETCH with 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 retrieve related entities - When fetching multiple collections,
DISTINCTis required (to eliminate duplicate rows from Cartesian product) - Using
LEFT JOIN FETCHallows you to retrieve the parent entity even when the association is null
Choosing Between Countermeasures
For simple association retrieval, @EntityGraph is convenient as it can be written concisely. When dealing with complex conditions or multiple associations, JPQL’s JOIN FETCH allows for flexible control.
Circular Reference Problems in Bidirectional Associations and Countermeasures
When converting entities to JSON in REST APIs, bidirectional associations cause circular reference errors.
Cause of Circular Reference Errors
// When the User entity has a list of Posts
// and the Post entity has a User (bidirectional association)
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
return userRepository.findById(id).orElseThrow();
}
// During JSON conversion:
// User -> Posts -> User -> Posts -> ... (infinite loop)
Solution Using @JsonIgnore
You can either add @JsonIgnore to one side of the association to exclude it from JSON conversion, or use a pair of @JsonManagedReference and @JsonBackReference.
@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;
}
Fundamental Solution Using DTO Pattern (Recommended)
The most recommended approach is to not return entities directly, but to 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
}
Using the DTO pattern, circular reference problems don’t occur, you can freely control the format of API responses, and you don’t expose the internal structure of entities to API clients, reducing security risks.
Practical Example: Complete Implementation of User and Post
Let’s look at practical sample code that integrates the knowledge 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 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); // post is also saved via cascade
}
}
By combining @OneToMany, @ManyToOne, cascade settings, FetchType, and N+1 countermeasures as explained above, you can design practical entities.
Summary and Best Practices
We’ve covered JPA association mapping. Finally, let’s summarize guidelines you can use in practice.
Recommended Settings
- Use LAZY for FetchType as a baseline - For performance optimization, explicitly specify
LAZY - Keep cascade to the minimum necessary - Use
CascadeType.ALLandREMOVEcarefully - Always set mappedBy for bidirectional associations - Specify the field name on the owner side of the association
- Be aware of the N+1 problem - Use
@EntityGraphorJOIN FETCHto efficiently retrieve the necessary data - Don’t return entities directly - In REST APIs, use DTOs to fundamentally avoid circular references
Basic Design Philosophy
We recommend prioritizing simplicity and starting with a simple design. Addressing performance issues only after they actually occur helps avoid complications from premature optimization. Prioritize the correctness of business logic over technical optimization.
JPA association mapping may feel complex at first, but once you grasp the basic patterns, you can use it effectively in practice. Try the content introduced in this article in actual code, and deepen your understanding gradually.
Related Articles
- How to Implement Exception Handling in Spring Boot REST APIs - Unified exception handling patterns including JPA exceptions
- Dependency Injection (DI) - For understanding Repository/Service composition
- How to Properly Configure and Tune Spring Boot’s HikariCP Connection Pool - DB connection settings to combine with JPA
- How to Write Unit Tests for Controllers Using MockMvc in Spring Boot - How to write tests mocking the Repository layer
- How to Loosely Couple Modules Using ApplicationEvent in Spring Boot - Designing propagation of entity change events
Frequently Asked Questions (FAQ)
Q. Which side should @OneToMany and @ManyToOne be placed on?
The basic rule is to place @ManyToOne on the “many” side, which has the foreign key. To make it bidirectional, add @OneToMany(mappedBy = "...") to the “one” side, and make the @ManyToOne side the owner.
Q. What happens if I don’t specify mappedBy?
JPA recognizes them as two independent associations, and an unintended intermediate table may be auto-generated. In bidirectional associations, always specify mappedBy on the inverse side (the @OneToMany side).
Q. Should I use LAZY or EAGER for FetchType?
The safe approach is to explicitly specify LAZY as a baseline and retrieve only where necessary with @EntityGraph or JOIN FETCH. EAGER tends to retrieve unnecessary data and can be a cause of the N+1 problem.
Q. Is it okay to use CascadeType.ALL?
It’s not recommended. Because REMOVE is included, there’s a risk that children will be unintentionally deleted when the parent is deleted. Explicitly specify only the necessary operations such as PERSIST and MERGE.
Q. What’s the easiest way to avoid the N+1 problem?
The easiest method is to add @EntityGraph(attributePaths = "...") to repository methods. For multiple associations or complex conditions, use JPQL’s JOIN FETCH accordingly.
Q. How do I choose between @ManyToMany and an intermediate entity?
If the intermediate table needs additional columns (registration date, status, etc.), decompose it into an intermediate entity and two @ManyToOne associations instead of @ManyToMany. If no attributes are needed, @ManyToMany is sufficient.