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:

  • @ManyToOne is placed on the “many” side (Post)
  • You can specify the foreign key column name with @JoinColumn (if omitted, user_id is 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
  • joinColumns specifies the foreign key on your own side, and inverseJoinColumns specifies the foreign key on the other side
  • In many-to-many relationships, Set is often used instead of List to 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 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 FETCH to explicitly retrieve related entities
  • When fetching multiple collections, DISTINCT is required (to eliminate duplicate rows from Cartesian product)
  • Using LEFT JOIN FETCH allows 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;
}

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.

  1. Use LAZY for FetchType as a baseline - For performance optimization, explicitly specify LAZY
  2. Keep cascade to the minimum necessary - Use CascadeType.ALL and REMOVE carefully
  3. Always set mappedBy for bidirectional associations - Specify the field name on the owner side of the association
  4. Be aware of the N+1 problem - Use @EntityGraph or JOIN FETCH to efficiently retrieve the necessary data
  5. 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.

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.