Are you having trouble figuring out how to express relationships between tables in entity classes when using JPA with Spring Boot?
This article explains association mapping using @OneToMany, @ManyToOne, and @ManyToMany annotations step by step, from the basics to the distinction between bidirectional and unidirectional relationships, cascade settings, and how to choose FetchType. It includes practical knowledge you can use right away, along with countermeasures for common pitfalls encountered in practice, such as the N+1 problem and circular references.
What is JPA Association Mapping?
JPA (Java Persistence API) association mapping is a mechanism for expressing relationships between database tables in Java entity classes.
In relational databases, relationships between tables are represented by foreign keys, but JPA allows you to handle these in an object-oriented way.
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., relationship 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 you to manipulate 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 most commonly used.
Basics of @ManyToOne and @OneToMany - Unidirectional Associations
What is @ManyToOne - The Annotation that Holds the Foreign Key on the Many Side
@ManyToOne is an annotation that represents a relationship where multiple entities reference a single related entity. In terms of table design, a foreign key column is created on the table side where @ManyToOne is annotated.
- Placement: Always attached to the field of the entity on the “many” side
- Default FetchType:
EAGER(it is recommended to explicitly specifyLAZY) - Foreign key column name: Specified with
@JoinColumn(name = "...")(defaults tofieldName_idif omitted) - Nullable control: Required references can be expressed with
@JoinColumn(nullable = false)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
What is @OneToMany - The Annotation for Accessing a Collection from the One Side
@OneToMany represents a relationship where one entity holds multiple related entities as a collection. In practice, it is mostly used in bidirectional associations combined with @ManyToOne.
- Placement: Attached to the collection field of the entity on the “one” side
- Default FetchType:
LAZY - When used bidirectionally,
mappedBymust always be specified (described later) - Collection types are typically
ListorSet; useSetif you want to avoid duplicates
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Post> posts = new ArrayList<>();
If you do not specify mappedBy in a unidirectional @OneToMany, an intermediate table will be automatically generated, so caution is needed.
Basic Attributes of @JoinColumn - name/referencedColumnName/nullable/unique
@JoinColumn is an annotation that controls the details of the foreign key column. The commonly used attributes are as follows.
name: Physical name of the foreign key column (defaults tofieldName_primaryKeyNameif omitted)referencedColumnName: Column name of the referenced table (defaults to the primary key if omitted)nullable: Whether the foreign key allows NULL (falsefor required references)unique: Whether to add a UNIQUE constraint to the foreign key (behavior equivalent to@OneToOne)insertable/updatable: Whether to include 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;
Even if you omit @JoinColumn, it will work, but explicitly writing it for production operations makes the schema intent clearer without relying on automatic DDL generation.
Let’s start by looking at the most commonly used one-to-many relationship. We’ll begin with unidirectional associations.
@ManyToOne Unidirectional
The many-to-one relationship is expressed with the @ManyToOne annotation. As an example, consider a 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)@JoinColumncan specify the name of the foreign key column (if omitted,user_idis automatically generated)- In this implementation, references from Post to User are possible, but you cannot access Post from User (unidirectional)
Use Cases for Unidirectional Associations
Unidirectional associations are used when navigation from only one direction is sufficient. They are suitable when you want to keep coupling between entities low or prioritize a simple design.
Note: In the case of unidirectional @OneToMany, if you do not specify @JoinColumn, an intermediate table is automatically generated. Usually, it is more appropriate to use unidirectional @ManyToOne or bidirectional associations.
Bidirectional Associations and the mappedBy Attribute
What is mappedBy - The Attribute That Indicates the Owner of the Association
mappedBy is an attribute that tells JPA which entity is the “owner” of the association in a bidirectional association. The side that physically holds the foreign key is the owner, and mappedBy is specified on the non-owner (inverse) side.
- Side to specify on:
@OneToManyside (in bidirectional cases) - Value to specify: The name of the field that references itself in the owner entity
- Effect of not specifying: JPA recognizes them as two independent associations and generates an unintended intermediate table
- Since foreign key updates are reflected only on the owner side, use helper methods to synchronize both sides
// User side (inverse side)
@OneToMany(mappedBy = "user")
private List<Post> posts = new ArrayList<>();
// Post side (owner - holds the foreign key)
@ManyToOne
@JoinColumn(name = "user_id")
private User user;
In the above example, the "user" in mappedBy = "user" refers to the user field in the Post class. The foreign key will not be updated unless you set the user on the Post side.
In practice, bidirectional associations that allow access from both directions are commonly used.
Implementation of 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;
}
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 holds the foreign key - Inverse side: The side with
@OneToMany(mappedBy = "user")(User)
The value of mappedBy specifies the field name on the owner side (the user field in the Post class).
Important: If you do not specify mappedBy, JPA will recognize them as two independent associations, resulting in an unintended table structure.
Helper Methods for Maintaining Consistency
In bidirectional associations, it is recommended to provide helper methods like addPost to maintain consistency on both sides. Using this method ensures that adding to the User’s list and setting the reference on the Post side happen simultaneously, reliably setting the association on both sides.
Many-to-Many Association Mapping with @ManyToMany
Many-to-many relationships are expressed using an intermediate table in the database, but in JPA, they can be described concisely with @ManyToMany.
@ManyToMany Unidirectional and Bidirectional
Let’s look at it with the 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 when making it bidirectional
@Entity
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToMany(mappedBy = "courses") // For bidirectional cases
private Set<Student> students = new HashSet<>();
}
Key points:
@JoinTableallows you to customize the name and column names of the intermediate tablejoinColumnsspecifies the foreign key on your side, andinverseJoinColumnsspecifies the foreign key on the other side- In many-to-many relationships,
Setis often used rather thanListto 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 this.
In such cases, 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 - Propagation of Operations to Related Entities
CascadeType controls whether operations on the parent entity are propagated to related entities.
Types of CascadeType
CascadeType includes the following types.
- PERSIST: When the parent is persisted, related entities are also persisted
- 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
Example of Cascade Behavior
@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", "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. In the case of REMOVE, deletion is propagated, so be careful about unintended data loss.
Best Practices for Cascade Settings
CascadeType.ALL may seem convenient, but it can cause unintended deletions. It is 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, simply 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) whose association with the parent entity has been broken. Unlike CascadeType.REMOVE, simply removing from the list deletes the child entity.
FetchType - Choosing a Data Fetching Strategy
FetchType controls when related entities are fetched.
Difference Between FetchType.LAZY and EAGER
// LAZY: Not fetched until actually accessed
@ManyToOne(fetch = FetchType.LAZY)
private User user;
// EAGER: Fetched simultaneously with the parent entity
@ManyToOne(fetch = FetchType.EAGER)
private User user;
With LAZY, SQL is issued only when the related entity is accessed, so it is memory-efficient and fetches only the necessary data. EAGER fetches related entities at the same time as the parent entity, which is convenient when data is always needed, but it may also fetch unnecessary data.
Default FetchType
Defaults differ depending on the annotation.
@ManyToOne,@OneToOne: EAGER (default)@OneToMany,@ManyToMany: LAZY (default)
Basic Principles for Choosing FetchType
The basic principle is to explicitly specify FetchType.LAZY and control the fetching 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 fetching with @EntityGraph or JOIN FETCH, or (3) using DTOs to fetch necessary data within the session.
The N+1 Problem and Its Solutions
If you want to comprehensively improve JPA performance, it is effective to also review the DB connection-side settings. If connection pool settings are not appropriate, even after resolving N+1, you may end up stalling on DB connection waits. For details, see How to Properly Configure and Tune HikariCP Connection Pool in Spring Boot.
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()); // SQL is issued for each post!
}
In this code, when the association is FetchType.LAZY, an SQL query is executed once to fetch all posts, and then N additional SQL queries (one for each post) are executed to fetch 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 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 names of the related entities you want to fetch in
attributePaths - Multiple associations can be fetched at the same time
- Internally, LEFT OUTER JOIN is used
Solution Using JPQL JOIN FETCH
When more flexible control is needed, 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 - When fetching multiple collections,
DISTINCTis needed (to eliminate duplicate rows from Cartesian products) - Using
LEFT JOIN FETCHallows you to fetch the parent entity even when the association is null
When to Use Each Approach
For simple association fetching, @EntityGraph is convenient because it can be written concisely. For complex conditions or multiple associations, JPQL’s JOIN FETCH allows for flexible control.
The Circular Reference Problem in Bidirectional Associations and Countermeasures
When converting entities to JSON in REST APIs, circular reference errors occur with bidirectional associations.
Cause of Circular Reference Errors
Circular references during JSON conversion often surface as exceptions in the end, so unifying exception handling across the REST API makes root cause identification and operations easier. For specific implementation patterns, see How to Implement Exception Handling in Spring Boot REST APIs.
// In the case of a bidirectional association where
// the User entity has a list of Posts and the Post entity has a User
@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 attach @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 the DTO Pattern (Recommended)
In a Controller that returns DTOs, combining @Valid on the input side also ensures consistency between requests and responses. For the usage of @Valid and the difference from @Validated, see How to Implement Validation Simply with the Spring Boot @Valid Annotation.
The most recommended approach is to use DTOs (Data Transfer Objects) 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); // Convert entity to DTO
}
Using the DTO pattern, circular reference problems do not occur, you can freely control the format of API responses, and since the internal structure of entities is not exposed to API clients, security risks are also reduced.
Practical Example: Complete Implementation of User and Post
Let’s look at practical sample code that integrates the knowledge 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); // post is also saved by cascade
}
}
By combining the @OneToMany, @ManyToOne, cascade settings, FetchType, and N+1 countermeasures explained so far, you can achieve practical entity design.
Summary and Best Practices
We have explained JPA association mapping. Finally, here is a summary of guidelines you can use in practice.
Recommended Settings
- Use LAZY for FetchType by default - For performance optimization, explicitly specify
LAZY - Keep cascade to the minimum necessary - Use
CascadeType.ALLandREMOVEcautiously - Always set mappedBy in bidirectional associations - Specify the field name on the owner side of the association
- Be aware of the N+1 problem - Utilize
@EntityGraphandJOIN FETCHto efficiently fetch necessary data - Do not return entities directly - Use DTOs in REST APIs to fundamentally avoid circular references
Basic Design Policy
We recommend prioritizing simplicity and starting with a simple design. Addressing performance issues only after they actually occur helps avoid complexity from premature optimization. Prioritize the accuracy of business logic over technical optimization.
JPA association mapping may seem complex at first, but once you grasp the basic patterns, you can use it effectively in practice. Try out the content introduced in this article in actual code and gradually deepen your understanding.
Related Articles
- How to Implement Exception Handling in Spring Boot REST APIs - Unified exception handling patterns including JPA exceptions
- Dependency Injection (DI) - For understanding the Repository/Service structure
- How to Properly Configure and Tune HikariCP Connection Pool in Spring Boot - DB connection settings to combine with JPA
- How to Write Unit Tests for Controllers Using MockMvc in Spring Boot - How to write tests that mock the Repository layer
- How to Achieve Loose Coupling Between Modules with Spring Boot ApplicationEvent - Design for propagating entity change events
Frequently Asked Questions (FAQ)
Q. Should @OneToMany or @ManyToOne be attached?
The basic rule is to attach @ManyToOne to the “many” side that holds the foreign key. When making 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 may recognize them as two independent associations and automatically generate an unintended intermediate table. In bidirectional associations, be sure to specify mappedBy on the inverse side (@OneToMany side).
Q. Should I use LAZY or EAGER for FetchType?
The safe approach is to explicitly specify LAZY and fetch only where necessary using @EntityGraph or JOIN FETCH. EAGER tends to fetch unnecessary data and can also be the cause of the N+1 problem.
Q. Is it OK to use CascadeType.ALL?
It is not recommended. Since REMOVE is included, there is a risk of unintentionally deleting children when deleting the parent. Explicitly specify only the operations you need, such as PERSIST and MERGE.
Q. What is the easiest way to avoid the N+1 problem?
The easiest method is to attach @EntityGraph(attributePaths = "...") to the Repository method. For multiple associations or complex conditions, use JPQL’s JOIN FETCH selectively.
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 plus two @ManyToOne instead of @ManyToMany. If no attributes are needed, @ManyToMany is sufficient.