Entity Relationships & Advanced Mapping

Table of Contents

Relationship Types in Relational Databases

In the real world, data is connected. A user writes posts. A post has comments. A post belongs to categories and has multiple tags. Modeling these connections correctly in JPA is one of the most important skills you will develop as a backend developer.

The Three Relationship Types

Every relationship between database tables falls into one of three categories:

One-to-One (1:1) — One record in table A relates to exactly one record in table B.

Example: One user has one profile. One profile belongs to one user.

users table          profiles table
+---------+          +----------+---------+
| id | .. |          | id | ... | user_id |
|----|-----|          |----|-----|---------|
| 1  | .. |  ←────→  | 1  | ... | 1       |
| 2  | .. |  ←────→  | 2  | ... | 2       |
+---------+          +----------+---------+

One-to-Many (1:N) — One record in table A relates to many records in table B.

Example: One user writes many posts. Each post belongs to one user.

users table          posts table
+---------+          +----------+-----------+
| id | .. |          | id | ... | author_id |
|----|-----|          |----|-----|-----------|
| 1  | .. |  ←──┐    | 1  | ... | 1         |
|    |    |     ├──  | 2  | ... | 1         |
|    |    |     └──  | 3  | ... | 1         |
| 2  | .. |  ←────   | 4  | ... | 2         |
+---------+          +----------+-----------+

Many-to-Many (M:N) — Many records in table A relate to many records in table B. This always requires a junction table (also called a join table or bridge table).

Example: A post can have many tags. A tag can belong to many posts.

posts table     post_tags (junction)     tags table
+----+-----+    +---------+--------+    +----+-------+
| id | ... |    | post_id | tag_id |    | id | name  |
|----|-----|    |---------|--------|    |----|-------|
| 1  | ... | ←─ | 1       | 1      | ─→ | 1  | Java  |
| 1  | ... | ←─ | 1       | 2      | ─→ | 2  | Spring|
| 2  | ... | ←─ | 2       | 2      | ─→ |    |       |
| 2  | ... | ←─ | 2       | 3      | ─→ | 3  | Docker|
+----+-----+    +---------+--------+    +----+-------+

How JPA Maps Relationships

In SQL, relationships are represented by foreign keys — a column in one table that references the primary key of another table. In JPA, relationships are represented by object references — a field in one entity that points to another entity.

JPA provides four annotations for mapping relationships:

Annotation Relationship Foreign Key Location
@OneToOne 1:1 Either side (usually the dependent side)
@ManyToOne N:1 The “many” side (the entity with the foreign key)
@OneToMany 1:N The “one” side (inverse of @ManyToOne)
@ManyToMany M:N Junction table (neither entity has the foreign key)

Let us explore each one in detail.


@OneToOne Mapping — User and Profile Example

A One-to-One relationship means each record in one table corresponds to exactly one record in another table. In our blog application, each user can have one detailed profile.

The Database Tables

CREATE TABLE users (
    id       BIGINT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(50) NOT NULL UNIQUE,
    email    VARCHAR(255) NOT NULL UNIQUE
    -- ... other fields
);

CREATE TABLE user_profiles (
    id          BIGINT AUTO_INCREMENT PRIMARY KEY,
    avatar_url  VARCHAR(500),
    website     VARCHAR(500),
    location    VARCHAR(100),
    github_url  VARCHAR(500),
    twitter_url VARCHAR(500),
    user_id     BIGINT NOT NULL UNIQUE,   -- Foreign key to users + UNIQUE (enforces 1:1)
    FOREIGN KEY (user_id) REFERENCES users(id)
);

The UNIQUE constraint on user_id enforces the one-to-one relationship at the database level — a user can have at most one profile.

The Entity Mapping

UserProfile entity (the owning side — has the foreign key):

@Entity
@Table(name = "user_profiles")
public class UserProfile {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "avatar_url", length = 500)
    private String avatarUrl;

    @Column(length = 500)
    private String website;

    @Column(length = 100)
    private String location;

    @Column(name = "github_url", length = 500)
    private String githubUrl;

    @Column(name = "twitter_url", length = 500)
    private String twitterUrl;

    // @OneToOne — this profile belongs to exactly one user.
    // @JoinColumn specifies the foreign key column in the user_profiles table.
    // This is the "owning side" because it contains the foreign key.
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false, unique = true)
    private User user;

    protected UserProfile() { }

    public UserProfile(User user) {
        this.user = user;
    }

    // getters and setters...
    public Long getId() { return id; }
    public String getAvatarUrl() { return avatarUrl; }
    public void setAvatarUrl(String avatarUrl) { this.avatarUrl = avatarUrl; }
    public String getWebsite() { return website; }
    public void setWebsite(String website) { this.website = website; }
    public String getLocation() { return location; }
    public void setLocation(String location) { this.location = location; }
    public String getGithubUrl() { return githubUrl; }
    public void setGithubUrl(String githubUrl) { this.githubUrl = githubUrl; }
    public String getTwitterUrl() { return twitterUrl; }
    public void setTwitterUrl(String twitterUrl) { this.twitterUrl = twitterUrl; }
    public User getUser() { return user; }
}

User entity (the inverse side — no foreign key in its table):

@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 50)
    private String username;

    @Column(nullable = false, unique = true)
    private String email;

    // @OneToOne with mappedBy — this is the INVERSE side.
    // "mappedBy = user" means: the UserProfile entity owns the relationship
    // through its "user" field. There is no foreign key column in the users table.
    //
    // CascadeType.ALL means: when we save/update/delete a User, the same
    // operation automatically cascades to the UserProfile.
    //
    // orphanRemoval = true means: if we set user.setProfile(null),
    // the orphaned profile is automatically deleted from the database.
    @OneToOne(mappedBy = "user", cascade = CascadeType.ALL,
              fetch = FetchType.LAZY, orphanRemoval = true)
    private UserProfile profile;

    // Helper method — keeps both sides of the relationship in sync
    public void setProfile(UserProfile profile) {
        this.profile = profile;
        if (profile != null) {
            profile.setUser(this);
        }
    }

    // ... other fields, getters, setters
    public Long getId() { return id; }
    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
    public UserProfile getProfile() { return profile; }
}

Using the One-to-One Relationship

@Service
public class UserService {

    @Transactional
    public User createUserWithProfile(String username, String email) {
        User user = new User(username, email, "tempHash");

        // Create a profile and attach it to the user
        UserProfile profile = new UserProfile(user);
        profile.setLocation("Ho Chi Minh City");
        profile.setWebsite("https://example.com");

        user.setProfile(profile);

        // Only save the User — CascadeType.ALL automatically saves the Profile too!
        // Hibernate generates:
        // INSERT INTO users (username, email, ...) VALUES (?, ?, ...)
        // INSERT INTO user_profiles (avatar_url, website, location, user_id, ...) VALUES (?, ?, ?, ?, ...)
        return userRepository.save(user);
    }
}

The Owning Side vs Inverse Side

This concept confuses many beginners. Here is the rule:

  • The owning side is the entity that has the @JoinColumn (the foreign key). In our example, UserProfile owns the relationship because user_profiles table has the user_id foreign key.
  • The inverse side is the entity with mappedBy. In our example, User is the inverse side.

Only changes to the owning side are persisted to the database. If you modify the relationship from the inverse side without updating the owning side, Hibernate ignores the change. That is why helper methods (like setProfile()) that update both sides are important.


@OneToMany and @ManyToOne — Post and Comments

This is the most common relationship type in web applications. In our blog, one post can have many comments, and each comment belongs to one post.

The @ManyToOne Side (Comment → Post)

The @ManyToOne side is always the owning side — the table that has the foreign key:

@Entity
@Table(name = "comments")
public class Comment {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, columnDefinition = "TEXT")
    private String content;

    // Many comments belong to one post.
    // The comments table has a "post_id" foreign key column.
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id", nullable = false)
    private Post post;

    // Many comments belong to one author.
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "author_id", nullable = false)
    private User author;

    // Self-referencing relationship: a comment can be a reply to another comment.
    // parent_id is NULL for top-level comments, or references another comment's id.
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "parent_id")
    private Comment parent;

    // Replies to this comment
    @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Comment> replies = new ArrayList<>();

    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @Column(name = "updated_at", nullable = false)
    private LocalDateTime updatedAt;

    @PrePersist
    protected void onCreate() {
        this.createdAt = LocalDateTime.now();
        this.updatedAt = LocalDateTime.now();
    }

    @PreUpdate
    protected void onUpdate() {
        this.updatedAt = LocalDateTime.now();
    }

    protected Comment() { }

    public Comment(String content, Post post, User author) {
        this.content = content;
        this.post = post;
        this.author = author;
    }

    // getters and setters...
    public Long getId() { return id; }
    public String getContent() { return content; }
    public void setContent(String content) { this.content = content; }
    public Post getPost() { return post; }
    public void setPost(Post post) { this.post = post; }
    public User getAuthor() { return author; }
    public void setAuthor(User author) { this.author = author; }
    public Comment getParent() { return parent; }
    public void setParent(Comment parent) { this.parent = parent; }
    public List<Comment> getReplies() { return replies; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public LocalDateTime getUpdatedAt() { return updatedAt; }

    // Helper method for adding a reply
    public void addReply(Comment reply) {
        replies.add(reply);
        reply.setParent(this);
    }
}

The @OneToMany Side (Post → Comments)

The @OneToMany side is the inverse side — it uses mappedBy:

@Entity
@Table(name = "posts")
public class Post {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 500)
    private String title;

    @Column(nullable = false, columnDefinition = "TEXT")
    private String content;

    // ... other fields (slug, excerpt, status, etc.)

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "author_id", nullable = false)
    private User author;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "category_id")
    private Category category;

    // One post has many comments.
    // "mappedBy = post" means: the Comment entity owns the relationship
    // through its "post" field. The foreign key is in the comments table.
    //
    // CascadeType.ALL: saving/deleting a post cascades to its comments.
    // orphanRemoval: removing a comment from this list deletes it from the DB.
    @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Comment> comments = new ArrayList<>();

    // ... timestamps, constructors, etc.

    // Helper method — keeps both sides in sync
    public void addComment(Comment comment) {
        comments.add(comment);
        comment.setPost(this);
    }

    public void removeComment(Comment comment) {
        comments.remove(comment);
        comment.setPost(null);
    }

    // getters...
    public List<Comment> getComments() { return comments; }
}

Using the One-to-Many Relationship

@Service
public class CommentService {

    private final CommentRepository commentRepository;
    private final PostRepository postRepository;
    private final UserRepository userRepository;

    public CommentService(CommentRepository commentRepository,
                          PostRepository postRepository,
                          UserRepository userRepository) {
        this.commentRepository = commentRepository;
        this.postRepository = postRepository;
        this.userRepository = userRepository;
    }

    // Add a top-level comment to a post
    @Transactional
    public Comment addComment(Long postId, Long authorId, String content) {
        Post post = postRepository.findById(postId)
                .orElseThrow(() -> new RuntimeException("Post not found"));
        User author = userRepository.findById(authorId)
                .orElseThrow(() -> new RuntimeException("User not found"));

        Comment comment = new Comment(content, post, author);
        // Hibernate generates: INSERT INTO comments (content, post_id, author_id, ...) VALUES (?, ?, ?, ...)
        return commentRepository.save(comment);
    }

    // Add a reply to an existing comment
    @Transactional
    public Comment addReply(Long parentCommentId, Long authorId, String content) {
        Comment parent = commentRepository.findById(parentCommentId)
                .orElseThrow(() -> new RuntimeException("Comment not found"));
        User author = userRepository.findById(authorId)
                .orElseThrow(() -> new RuntimeException("User not found"));

        Comment reply = new Comment(content, parent.getPost(), author);
        parent.addReply(reply);  // Sets the parent reference and adds to the list

        return commentRepository.save(reply);
    }
}

Why Initialize Collections?

Notice that we initialize the collection field:

private List<Comment> comments = new ArrayList<>();

This prevents NullPointerException when calling post.getComments() on a post with no comments. Without initialization, the field would be null for a new (unsaved) entity.


@ManyToMany — Posts and Tags

A Many-to-Many relationship requires a junction table. In our blog, a post can have multiple tags, and a tag can be assigned to multiple posts.

The Database Structure

-- Tags table
CREATE TABLE tags (
    id   BIGINT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(50) NOT NULL UNIQUE,
    slug VARCHAR(50) NOT NULL UNIQUE
);

-- Junction table — connects posts and tags
CREATE TABLE post_tags (
    post_id BIGINT NOT NULL,
    tag_id  BIGINT NOT NULL,
    PRIMARY KEY (post_id, tag_id),
    FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
    FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
);

The Entity Mapping

Tag entity:

@Entity
@Table(name = "tags")
public class Tag {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 50)
    private String name;

    @Column(nullable = false, unique = true, length = 50)
    private String slug;

    // The inverse side of the ManyToMany relationship.
    // "mappedBy = tags" points to the "tags" field in the Post entity.
    @ManyToMany(mappedBy = "tags")
    private Set<Post> posts = new HashSet<>();

    protected Tag() { }

    public Tag(String name, String slug) {
        this.name = name;
        this.slug = slug;
    }

    public Long getId() { return id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getSlug() { return slug; }
    public void setSlug(String slug) { this.slug = slug; }
    public Set<Post> getPosts() { return posts; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Tag tag = (Tag) o;
        return id != null && id.equals(tag.id);
    }

    @Override
    public int hashCode() {
        return getClass().hashCode();
    }
}

Post entity — add the tags field:

@Entity
@Table(name = "posts")
public class Post {

    // ... existing fields (id, title, content, author, category, comments, etc.)

    // @ManyToMany defines the many-to-many relationship.
    // This is the OWNING SIDE — it controls the junction table.
    //
    // @JoinTable specifies the junction table details:
    // - name: the junction table name ("post_tags")
    // - joinColumns: the foreign key pointing to THIS entity ("post_id")
    // - inverseJoinColumns: the foreign key pointing to the OTHER entity ("tag_id")
    @ManyToMany
    @JoinTable(
        name = "post_tags",
        joinColumns = @JoinColumn(name = "post_id"),
        inverseJoinColumns = @JoinColumn(name = "tag_id")
    )
    private Set<Tag> tags = new HashSet<>();

    // Helper methods — ALWAYS update both sides of the relationship
    public void addTag(Tag tag) {
        this.tags.add(tag);
        tag.getPosts().add(this);
    }

    public void removeTag(Tag tag) {
        this.tags.remove(tag);
        tag.getPosts().remove(this);
    }

    public Set<Tag> getTags() { return tags; }
}

Why Use Set Instead of List?

For @ManyToMany, always use Set instead of List. Here is why:

// ❌ BAD — List with @ManyToMany
@ManyToMany
private List<Tag> tags = new ArrayList<>();
// When you remove a tag, Hibernate:
// 1. DELETE ALL rows from post_tags for this post
// 2. RE-INSERT all remaining tags
// This is extremely inefficient!

// ✅ GOOD — Set with @ManyToMany
@ManyToMany
private Set<Tag> tags = new HashSet<>();
// When you remove a tag, Hibernate:
// 1. DELETE only the one row from post_tags
// Much more efficient!

The reason is that Hibernate cannot efficiently determine which specific rows to delete in a List (because Lists allow duplicates and maintain order). With a Set, each element is unique, so Hibernate knows exactly which junction table row to delete.

Using the Many-to-Many Relationship

@Service
public class PostService {

    @Transactional
    public Post createPostWithTags(String title, String content, 
                                    Long authorId, List<Long> tagIds) {
        User author = userRepository.findById(authorId).orElseThrow();

        Post post = new Post(title, generateSlug(title), content);
        post.setAuthor(author);

        // Load tags and add them to the post
        if (tagIds != null && !tagIds.isEmpty()) {
            List<Tag> tags = tagRepository.findAllById(tagIds);
            tags.forEach(post::addTag);  // Uses the helper method
        }

        // Hibernate generates:
        // INSERT INTO posts (...) VALUES (...)
        // INSERT INTO post_tags (post_id, tag_id) VALUES (?, ?)
        // INSERT INTO post_tags (post_id, tag_id) VALUES (?, ?)
        return postRepository.save(post);
    }

    @Transactional
    public Post addTagToPost(Long postId, Long tagId) {
        Post post = postRepository.findById(postId).orElseThrow();
        Tag tag = tagRepository.findById(tagId).orElseThrow();
        
        post.addTag(tag);
        // Hibernate generates: INSERT INTO post_tags (post_id, tag_id) VALUES (?, ?)
        return postRepository.save(post);
    }

    @Transactional
    public Post removeTagFromPost(Long postId, Long tagId) {
        Post post = postRepository.findById(postId).orElseThrow();
        Tag tag = tagRepository.findById(tagId).orElseThrow();
        
        post.removeTag(tag);
        // Hibernate generates: DELETE FROM post_tags WHERE post_id = ? AND tag_id = ?
        return postRepository.save(post);
    }
}

Cascade Types and Orphan Removal

What is Cascading?

Cascading means propagating an operation from a parent entity to its related child entities. Without cascading, you would need to explicitly save each related entity.

Cascade Types

@OneToMany(mappedBy = "post", cascade = CascadeType.ALL)
private List<Comment> comments = new ArrayList<>();

Here are all available cascade types:

Cascade Type Effect
PERSIST When the parent is saved, also save new child entities
MERGE When the parent is updated, also update child entities
REMOVE When the parent is deleted, also delete child entities
REFRESH When the parent is refreshed from DB, also refresh children
DETACH When the parent is detached from the session, also detach children
ALL All of the above combined

Choosing the Right Cascade

// CascadeType.ALL — use for strong parent-child relationships
// where children cannot exist without the parent.
// Example: Post → Comments (comments make no sense without their post)
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL)
private List<Comment> comments;

// CascadeType.PERSIST + MERGE — use when you want to save/update
// children through the parent, but NOT delete them automatically.
// Example: User → Posts (deleting a user should NOT delete their posts — 
// the posts might need to be reassigned to another author)
@OneToMany(mappedBy = "author", cascade = {CascadeType.PERSIST, CascadeType.MERGE})
private List<Post> posts;

// No cascade — use when the relationship is a loose reference.
// Example: Post → Category (the category exists independently of any post)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private Category category;  // No cascade — categories are managed separately

Orphan Removal

orphanRemoval = true automatically deletes child entities when they are removed from the parent’s collection:

@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();
@Transactional
public void removeComment(Long postId, Long commentId) {
    Post post = postRepository.findById(postId).orElseThrow();
    
    // Find and remove the comment from the post's collection
    post.getComments().removeIf(c -> c.getId().equals(commentId));
    
    // Because of orphanRemoval = true, Hibernate automatically generates:
    // DELETE FROM comments WHERE id = ?
    // We did NOT call commentRepository.delete() — the orphan removal did it.
}

Without orphanRemoval, removing a comment from the list would only break the in-memory relationship. The comment would still exist in the database as an orphan — it has a post_id pointing to the post, but the post no longer knows about it.

Cascade vs Foreign Key ON DELETE CASCADE

These are different mechanisms that do similar things:

Feature JPA Cascade SQL ON DELETE CASCADE
Where Java code (Hibernate) Database level
Triggers Entity operations (save, delete) SQL DELETE on parent table
Lifecycle callbacks Yes (@PreRemove, etc.) No
Auditing Yes No
Performance Loads entities first, then deletes Direct SQL, faster
Safety Hibernate-controlled Database-enforced

For most applications, use JPA cascades because they respect lifecycle callbacks, auditing, and other Hibernate features. Use SQL ON DELETE CASCADE as a safety net at the database level.


Fetch Strategies — LAZY vs EAGER Loading

What is Fetch Strategy?

The fetch strategy determines when Hibernate loads related entities. There are two options:

FetchType.EAGER — Load the related entity immediately, together with the parent.

@ManyToOne(fetch = FetchType.EAGER)  // Load author WITH every post query
@JoinColumn(name = "author_id")
private User author;

When you load a Post, Hibernate generates a JOIN query that also loads the User:

SELECT p.*, u.* FROM posts p JOIN users u ON p.author_id = u.id WHERE p.id = ?

FetchType.LAZY — Do not load the related entity until it is explicitly accessed.

@ManyToOne(fetch = FetchType.LAZY)  // Load author only when post.getAuthor() is called
@JoinColumn(name = "author_id")
private User author;

When you load a Post, Hibernate only loads the Post data:

SELECT p.* FROM posts p WHERE p.id = ?

The User is loaded later, only if you call post.getAuthor():

-- This query runs when you call post.getAuthor()
SELECT u.* FROM users u WHERE u.id = ?

Default Fetch Types

JPA has different defaults for different relationship types:

Annotation Default Fetch Reason
@ManyToOne EAGER Loading a single related entity is cheap
@OneToOne EAGER Loading a single related entity is cheap
@OneToMany LAZY Loading a collection could be expensive
@ManyToMany LAZY Loading a collection could be expensive

The Golden Rule: Always Use LAZY

Override the defaults and use FetchType.LAZY everywhere:

// ALWAYS specify LAZY — even for @ManyToOne and @OneToOne
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private User author;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private Category category;

@OneToOne(mappedBy = "user", fetch = FetchType.LAZY)
private UserProfile profile;

// Collections are LAZY by default, but being explicit is clear
@OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
private List<Comment> comments;

@ManyToMany(fetch = FetchType.LAZY)
private Set<Tag> tags;

Why? Because EAGER loading is a global setting — once set, it applies to every query that loads the entity. If Post eagerly loads Author, then every single query that returns a Post also loads the Author, even when the Author is not needed. This leads to the N+1 problem at scale.

With LAZY, you load related entities only when and where you need them, using JOIN FETCH or @EntityGraph for specific queries. This gives you precise control over performance.


The N+1 Problem and How to Solve It

The N+1 problem is the most common performance issue in applications using an ORM. Understanding and solving it is critical.

What is the N+1 Problem?

Imagine you want to display a list of 10 posts with their author names.

With LAZY loading (the default we recommended):

@Transactional(readOnly = true)
public void printPosts() {
    List<Post> posts = postRepository.findAll();  // Query 1: SELECT * FROM posts
    
    for (Post post : posts) {
        // Each call to getAuthor() triggers a separate SELECT!
        String authorName = post.getAuthor().getUsername();
        System.out.println(post.getTitle() + " by " + authorName);
    }
}

Hibernate generates:

-- Query 1: Load all posts
SELECT * FROM posts;                              -- Returns 10 posts

-- Queries 2-11: Load each author individually (one per post)
SELECT * FROM users WHERE id = 1;                -- For post 1's author
SELECT * FROM users WHERE id = 1;                -- For post 2's author (same author, but Hibernate may cache)
SELECT * FROM users WHERE id = 2;                -- For post 3's author
SELECT * FROM users WHERE id = 3;                -- For post 4's author
-- ... and so on for all 10 posts

That is 1 query for the posts + N queries for the authors = N+1 queries. If you have 100 posts, that is 101 queries. With 1000 posts, 1001 queries. The application becomes slower and slower as data grows.

With EAGER loading, the same problem occurs — Hibernate just triggers the extra queries earlier (when loading the posts) instead of later (when accessing the author).

Write a JPQL query with JOIN FETCH to load posts and authors in a single query:

public interface PostRepository extends JpaRepository<Post, Long> {

    // One single query loads posts AND their authors
    @Query("SELECT p FROM Post p JOIN FETCH p.author")
    List<Post> findAllWithAuthors();

    // With category too
    @Query("SELECT p FROM Post p JOIN FETCH p.author LEFT JOIN FETCH p.category")
    List<Post> findAllWithAuthorsAndCategories();

    // With pagination — NOTE: JOIN FETCH and Pageable require special handling
    @Query("SELECT p FROM Post p JOIN FETCH p.author WHERE p.status = :status")
    List<Post> findByStatusWithAuthors(@Param("status") String status);
}

Generated SQL:

-- One query loads everything!
SELECT p.*, u.* 
FROM posts p 
JOIN users u ON p.author_id = u.id;

1 query instead of N+1. Dramatic performance improvement.

@EntityGraph tells JPA to eagerly fetch specific associations for a particular query:

public interface PostRepository extends JpaRepository<Post, Long> {

    // @EntityGraph overrides LAZY fetch for this specific query
    @EntityGraph(attributePaths = {"author"})
    List<Post> findByStatus(String status);

    // Load multiple relationships
    @EntityGraph(attributePaths = {"author", "category"})
    List<Post> findByStatusOrderByCreatedAtDesc(String status);

    // Works with findById too
    @EntityGraph(attributePaths = {"author", "category", "tags"})
    Optional<Post> findWithAllDetailsById(Long id);
}

@EntityGraph generates a LEFT JOIN query:

SELECT p.*, u.*, c.* 
FROM posts p 
LEFT JOIN users u ON p.author_id = u.id 
LEFT JOIN categories c ON p.category_id = c.id 
WHERE p.status = ?

Solution 3: Batch Fetching (Good for Collections)

Instead of loading related entities one by one, Hibernate can load them in batches:

@Entity
@Table(name = "posts")
public class Post {

    // @BatchSize tells Hibernate: when loading comments for multiple posts,
    // load them in batches of 25 instead of one at a time.
    @OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
    @BatchSize(size = 25)
    private List<Comment> comments = new ArrayList<>();
}

Without @BatchSize, loading comments for 100 posts generates 100 queries:

SELECT * FROM comments WHERE post_id = 1;
SELECT * FROM comments WHERE post_id = 2;
-- ... 98 more queries

With @BatchSize(size = 25), it generates only 4 queries:

SELECT * FROM comments WHERE post_id IN (1, 2, 3, ..., 25);
SELECT * FROM comments WHERE post_id IN (26, 27, 28, ..., 50);
SELECT * FROM comments WHERE post_id IN (51, 52, 53, ..., 75);
SELECT * FROM comments WHERE post_id IN (76, 77, 78, ..., 100);

You can also set a global default batch size in application.properties:

spring.jpa.properties.hibernate.default_batch_fetch_size=25

Solution 4: DTO Projection (Best for Read-Only Use Cases)

Skip the entity entirely and load exactly the data you need:

public interface PostRepository extends JpaRepository<Post, Long> {

    @Query("SELECT new com.example.blogapi.dto.PostWithAuthorDto(" +
           "p.id, p.title, p.status, p.createdAt, " +
           "p.author.id, p.author.username, p.author.fullName) " +
           "FROM Post p WHERE p.status = :status")
    List<PostWithAuthorDto> findPostsWithAuthorInfo(@Param("status") String status);
}

This generates one optimized query that loads exactly the columns needed — no entities, no lazy loading issues, no N+1 problem.

Which Solution to Choose?

Scenario Best Solution
Loading single entity with relationships JOIN FETCH or @EntityGraph
Loading list with a single related entity JOIN FETCH or @EntityGraph
Loading list with collections (comments, tags) @BatchSize or DTO projection
API responses, read-only views DTO projection
Pagination with related entities @EntityGraph

Bidirectional vs Unidirectional Relationships

Unidirectional — Only One Side Knows About the Relationship

// Comment knows about Post, but Post does NOT know about Comments
@Entity
public class Comment {
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id")
    private Post post;  // Comment → Post navigation
}

@Entity
public class Post {
    // No comments field — Post does not know about its comments
}

To get comments for a post, you query through the repository:

public interface CommentRepository extends JpaRepository<Comment, Long> {
    List<Comment> findByPostId(Long postId);
}

Bidirectional — Both Sides Know About the Relationship

@Entity
public class Comment {
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id")
    private Post post;  // Comment → Post navigation
}

@Entity
public class Post {
    @OneToMany(mappedBy = "post", cascade = CascadeType.ALL)
    private List<Comment> comments;  // Post → Comments navigation
}

Both directions are navigable: comment.getPost() and post.getComments().

When to Use Which?

Use Unidirectional When Use Bidirectional When
You only navigate in one direction You navigate in both directions frequently
The collection side could be very large The collection is reasonably sized
Simpler model is preferred Cascade operations are needed
Performance is critical (avoids loading large collections) Convenience matters (access children through parent)

Practical recommendation for our blog:

  • Post → AuthorUnidirectional @ManyToOne only. You rarely need to navigate from User to all their posts through the entity — use a repository query instead.
  • Post → CommentsBidirectional. You often want post.getComments() and cascade deletes.
  • Post → CategoryUnidirectional @ManyToOne only. Navigate from Category to posts via repository queries.
  • Post ↔ TagsBidirectional @ManyToMany. Both navigations are useful.

The Danger of Bidirectional @OneToMany on Large Collections

// ⚠️ DANGEROUS — if a user has 10,000 posts, accessing user.getPosts()
// loads all 10,000 posts into memory!
@Entity
public class User {
    @OneToMany(mappedBy = "author")
    private List<Post> posts;  // Could be HUGE
}

Instead, use a repository query with pagination:

// ✅ SAFE — loads only 10 posts at a time
public interface PostRepository extends JpaRepository<Post, Long> {
    Page<Post> findByAuthorId(Long authorId, Pageable pageable);
}

Rule of thumb: If a collection could grow unbounded, do NOT map it as a @OneToMany. Use a repository query instead.


Best Practices for Entity Design

Here is a consolidated list of best practices gathered from everything we have covered:

Always Use LAZY Fetching

// Every relationship should be LAZY
@ManyToOne(fetch = FetchType.LAZY)
@OneToOne(fetch = FetchType.LAZY)
@OneToMany(fetch = FetchType.LAZY)   // default, but be explicit
@ManyToMany(fetch = FetchType.LAZY)  // default, but be explicit

Use JOIN FETCH, @EntityGraph, or @BatchSize for specific queries that need eager loading.

Use Set for @ManyToMany, List for @OneToMany

@OneToMany(mappedBy = "post")
private List<Comment> comments = new ArrayList<>();    // List is fine for @OneToMany

@ManyToMany
private Set<Tag> tags = new HashSet<>();               // Set is required for efficient @ManyToMany

Always Initialize Collections

// ✅ Always initialize
private List<Comment> comments = new ArrayList<>();
private Set<Tag> tags = new HashSet<>();

// ❌ Never leave null
private List<Comment> comments;  // NullPointerException on post.getComments()

Implement equals() and hashCode() Correctly

// Use the id field, handle null id for new entities
@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    Post post = (Post) o;
    return id != null && id.equals(post.id);
}

@Override
public int hashCode() {
    return getClass().hashCode();  // Constant — safe even before id is assigned
}

Write Helper Methods for Bidirectional Relationships

// Always keep both sides in sync
public void addComment(Comment comment) {
    comments.add(comment);
    comment.setPost(this);
}

public void removeComment(Comment comment) {
    comments.remove(comment);
    comment.setPost(null);
}

public void addTag(Tag tag) {
    tags.add(tag);
    tag.getPosts().add(this);
}

public void removeTag(Tag tag) {
    tags.remove(tag);
    tag.getPosts().remove(this);
}

Use @JsonIgnore to Prevent Infinite Loops

Bidirectional relationships cause infinite recursion during JSON serialization:

Jackson serializes Post → includes comments →
each Comment includes post → includes comments →
each Comment includes post → ... INFINITE LOOP

Fix with @JsonIgnore on the back-reference:

@Entity
public class Comment {
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id")
    @JsonIgnore  // Do not serialize the post when serializing a comment
    private Post post;
}

The real solution is DTOs (Lecture 8) — map entities to DTOs before serialization. This gives you full control over the JSON structure without polluting entities with Jackson annotations.

Avoid Cascade on @ManyToOne

// ❌ DANGEROUS — saving a comment should NOT cascade changes to the post
@ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "post_id")
private Post post;

// ✅ CORRECT — no cascade on @ManyToOne
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
private Post post;

Cascade flows from parent to child (Post → Comments), never from child to parent (Comment → Post).

Do Not Map Unbounded Collections

// ❌ User could have millions of activities — loading them all will crash the app
@OneToMany(mappedBy = "user")
private List<Activity> activities;

// ✅ Use a repository query instead
// PostRepository.findByAuthorId(userId, pageable)

Hands-on: Implement Full Blog Schema with Relationships

Let us bring everything together and implement the complete blog schema with all relationships properly mapped.

Step 1: Complete Entity Implementation

Here are the final versions of all entities with properly configured relationships. We will focus on the relationship annotations — refer to previous lectures for field-level mapping details.

User.java — with profile relationship:

@Entity
@Table(name = "users")
public class User extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 50)
    private String username;

    @Column(nullable = false, unique = true)
    private String email;

    @Column(name = "password_hash", nullable = false)
    private String passwordHash;

    @Column(name = "full_name", length = 100)
    private String fullName;

    @Column(columnDefinition = "TEXT")
    private String bio;

    @Column(nullable = false, length = 20)
    private String role = "ROLE_USER";

    @Column(name = "is_active", nullable = false)
    private boolean active = true;

    // Relationships
    @OneToOne(mappedBy = "user", cascade = CascadeType.ALL,
              fetch = FetchType.LAZY, orphanRemoval = true)
    @JsonIgnore
    private UserProfile profile;

    // No @OneToMany for posts — the collection could be huge.
    // Use PostRepository.findByAuthorId() instead.

    protected User() { }

    public User(String username, String email, String passwordHash) {
        this.username = username;
        this.email = email;
        this.passwordHash = passwordHash;
    }

    // Helper
    public void setProfile(UserProfile profile) {
        this.profile = profile;
        if (profile != null) {
            profile.setUser(this);
        }
    }

    // ... getters and setters
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
    public String getPasswordHash() { return passwordHash; }
    public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
    public String getFullName() { return fullName; }
    public void setFullName(String fullName) { this.fullName = fullName; }
    public String getBio() { return bio; }
    public void setBio(String bio) { this.bio = bio; }
    public String getRole() { return role; }
    public void setRole(String role) { this.role = role; }
    public boolean isActive() { return active; }
    public void setActive(boolean active) { this.active = active; }
    public UserProfile getProfile() { return profile; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return id != null && id.equals(user.id);
    }

    @Override
    public int hashCode() { return getClass().hashCode(); }
}

Post.java — with all relationships:

@Entity
@Table(name = "posts")
public class Post extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 500)
    private String title;

    @Column(nullable = false, length = 500, unique = true)
    private String slug;

    @Column(nullable = false, columnDefinition = "TEXT")
    private String content;

    @Column(length = 1000)
    private String excerpt;

    @Column(nullable = false, length = 20)
    private String status = "DRAFT";

    @Column(name = "published_at")
    private LocalDateTime publishedAt;

    // --- Relationships ---

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "author_id", nullable = false)
    private User author;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "category_id")
    private Category category;

    @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Comment> comments = new ArrayList<>();

    @ManyToMany
    @JoinTable(
        name = "post_tags",
        joinColumns = @JoinColumn(name = "post_id"),
        inverseJoinColumns = @JoinColumn(name = "tag_id")
    )
    private Set<Tag> tags = new HashSet<>();

    protected Post() { }

    public Post(String title, String slug, String content) {
        this.title = title;
        this.slug = slug;
        this.content = content;
    }

    // --- Helper Methods ---
    public void addComment(Comment comment) {
        comments.add(comment);
        comment.setPost(this);
    }

    public void removeComment(Comment comment) {
        comments.remove(comment);
        comment.setPost(null);
    }

    public void addTag(Tag tag) {
        tags.add(tag);
        tag.getPosts().add(this);
    }

    public void removeTag(Tag tag) {
        tags.remove(tag);
        tag.getPosts().remove(this);
    }

    // --- Getters and Setters ---
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }
    public String getSlug() { return slug; }
    public void setSlug(String slug) { this.slug = slug; }
    public String getContent() { return content; }
    public void setContent(String content) { this.content = content; }
    public String getExcerpt() { return excerpt; }
    public void setExcerpt(String excerpt) { this.excerpt = excerpt; }
    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }
    public LocalDateTime getPublishedAt() { return publishedAt; }
    public void setPublishedAt(LocalDateTime publishedAt) { this.publishedAt = publishedAt; }
    public User getAuthor() { return author; }
    public void setAuthor(User author) { this.author = author; }
    public Category getCategory() { return category; }
    public void setCategory(Category category) { this.category = category; }
    public List<Comment> getComments() { return comments; }
    public Set<Tag> getTags() { return tags; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Post post = (Post) o;
        return id != null && id.equals(post.id);
    }

    @Override
    public int hashCode() { return getClass().hashCode(); }
}

Step 2: Repository with Relationship-Aware Queries

PostRepository.java:

public interface PostRepository extends JpaRepository<Post, Long> {

    // Load post with author and category (no N+1 for these)
    @Query("SELECT p FROM Post p " +
           "JOIN FETCH p.author " +
           "LEFT JOIN FETCH p.category " +
           "WHERE p.id = :id")
    Optional<Post> findByIdWithDetails(@Param("id") Long id);

    // Load post with ALL relationships (for full detail page)
    @Query("SELECT DISTINCT p FROM Post p " +
           "JOIN FETCH p.author " +
           "LEFT JOIN FETCH p.category " +
           "LEFT JOIN FETCH p.tags " +
           "WHERE p.id = :id")
    Optional<Post> findByIdWithAllDetails(@Param("id") Long id);

    // Note: we do NOT fetch comments here because comments should be
    // loaded separately with pagination (a post could have thousands of comments).
    // Use CommentRepository.findByPostId(postId, pageable) instead.

    // List posts with authors for list page
    @EntityGraph(attributePaths = {"author", "category"})
    Page<Post> findByStatus(String status, Pageable pageable);

    // Posts by tag
    @Query("SELECT p FROM Post p JOIN p.tags t WHERE t.slug = :tagSlug AND p.status = 'PUBLISHED'")
    Page<Post> findByTagSlug(@Param("tagSlug") String tagSlug, Pageable pageable);

    // Posts by category
    @Query("SELECT p FROM Post p WHERE p.category.slug = :categorySlug AND p.status = 'PUBLISHED'")
    Page<Post> findByCategorySlug(@Param("categorySlug") String categorySlug, Pageable pageable);
}

CommentRepository.java:

public interface CommentRepository extends JpaRepository<Comment, Long> {

    // Get top-level comments for a post (parent_id IS NULL) with pagination
    @Query("SELECT c FROM Comment c " +
           "JOIN FETCH c.author " +
           "WHERE c.post.id = :postId AND c.parent IS NULL " +
           "ORDER BY c.createdAt DESC")
    Page<Comment> findTopLevelByPostId(@Param("postId") Long postId, Pageable pageable);

    // Get replies for a specific comment
    @Query("SELECT c FROM Comment c JOIN FETCH c.author WHERE c.parent.id = :parentId ORDER BY c.createdAt ASC")
    List<Comment> findRepliesByParentId(@Param("parentId") Long parentId);

    // Count comments for a post
    long countByPostId(Long postId);
}

Step 3: Test the Relationships

# Assume users and categories exist from Lecture 5 setup

# Create tags
docker exec -it blogdb mariadb -u bloguser -pblogpass blogdb \
  -e "INSERT INTO tags (name, slug) VALUES ('Java', 'java'), ('Spring Boot', 'spring-boot'), ('JPA', 'jpa') ON DUPLICATE KEY UPDATE name=name;"

# Create a post with tags via your API
curl -X POST http://localhost:8080/api/posts \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Entity Relationships in JPA",
    "content": "Understanding JPA relationships is crucial...",
    "excerpt": "Learn how to map entity relationships",
    "authorId": 1,
    "categoryId": 1,
    "tagIds": [1, 2, 3]
  }'

# Add a comment
curl -X POST http://localhost:8080/api/posts/1/comments \
  -H "Content-Type: application/json" \
  -d '{"content": "Great explanation!", "authorId": 2}'

# Add a reply to that comment
curl -X POST http://localhost:8080/api/comments/1/replies \
  -H "Content-Type: application/json" \
  -d '{"content": "Thanks! Glad you liked it.", "authorId": 1}'

# Get post with all details
curl http://localhost:8080/api/posts/1

# Get comments for a post with pagination
curl "http://localhost:8080/api/posts/1/comments?page=0&size=10"

# Get posts by tag
curl "http://localhost:8080/api/posts?tag=spring-boot&page=0&size=10"

Step 4: Watch the SQL Output

With spring.jpa.show-sql=true, observe how different queries perform:

-- findByIdWithAllDetails: ONE query loads post + author + category + tags
SELECT DISTINCT p.*, u.*, c.*, t.*
FROM posts p
JOIN users u ON p.author_id = u.id
LEFT JOIN categories c ON p.category_id = c.id
LEFT JOIN post_tags pt ON p.id = pt.post_id
LEFT JOIN tags t ON pt.tag_id = t.id
WHERE p.id = ?

-- findTopLevelByPostId: Separate query for comments (with pagination)
SELECT c.*, u.*
FROM comments c
JOIN users u ON c.author_id = u.id
WHERE c.post_id = ? AND c.parent_id IS NULL
ORDER BY c.created_at DESC
LIMIT ? OFFSET ?

Step 5: Exercises

  1. Add bookmark functionality: Create a @ManyToMany relationship between User and Post for bookmarks. Create a junction table user_bookmarks and implement POST /api/users/{id}/bookmarks/{postId} and GET /api/users/{id}/bookmarks endpoints.
  2. Implement comment counts efficiently: Instead of loading all comments to count them, add a @Formula or use a repository countByPostId() method. Display the comment count in post list responses.
  3. Test cascade delete: Delete a post that has comments and tags. Verify that comments are deleted (CascadeType.ALL) and that the post_tags junction rows are removed, but the tags themselves remain.
  4. Profile the N+1 problem: Temporarily remove JOIN FETCH from findByStatus and add @EntityGraph only for author. Then access post.getCategory().getName() in a loop. Count the queries in the console. Now add category to the @EntityGraph and compare.
  5. Self-referencing comments: Test nested replies — create a comment, reply to it, and reply to the reply. Query the tree structure and display it hierarchically.

Summary

Entity relationships are the backbone of any data-driven application. This lecture covered:

  • @OneToOne: One user has one profile. Use @JoinColumn on the owning side, mappedBy on the inverse side. Use CascadeType.ALL and orphanRemoval for strong parent-child bonds.
  • @OneToMany / @ManyToOne: One post has many comments. @ManyToOne (with @JoinColumn) is the owning side. @OneToMany (with mappedBy) is the inverse side. Use helper methods to keep both sides in sync.
  • @ManyToMany: Posts and tags through a junction table. Use @JoinTable on the owning side. Always use Set (not List) for efficient add/remove operations.
  • Cascade types: ALL for strong parent-child relationships, PERSIST + MERGE for moderate coupling, none for loose references. Never cascade on @ManyToOne.
  • Fetch strategies: Always use FetchType.LAZY. Override with JOIN FETCH, @EntityGraph, or @BatchSize for specific queries.
  • N+1 problem: 1 query for parents + N queries for children. Solve with JOIN FETCH (JPQL), @EntityGraph (derived queries), @BatchSize (collections), or DTO projections (read-only).
  • Bidirectional vs Unidirectional: Use bidirectional when you need navigation in both directions and cascade operations. Use unidirectional for loose references and potentially large collections.
  • Best practices: LAZY everywhere, Set for ManyToMany, initialize collections, correct equals/hashCode, helper methods for bidirectional sync, @JsonIgnore to prevent infinite loops, avoid mapping unbounded collections.

What is Next

In Lecture 8, we will implement the DTO Pattern, Validation & Error Handling — the proper way to separate your API layer from your domain model. You will learn how to create request/response DTOs, validate input with Jakarta Validation annotations, and build a global exception handler for consistent error responses.


Quick Reference

Concept Description
@OneToOne One entity relates to exactly one other entity
@ManyToOne Many entities relate to one entity (owns the foreign key)
@OneToMany One entity relates to many entities (inverse of @ManyToOne)
@ManyToMany Many entities relate to many entities (junction table)
@JoinColumn Specifies the foreign key column (owning side)
@JoinTable Specifies the junction table (ManyToMany owning side)
mappedBy Declares the inverse side of a relationship
Owning side The entity that contains the foreign key — changes here are persisted
Inverse side The entity with mappedBy — changes here are NOT persisted directly
CascadeType.ALL Propagate all operations (persist, merge, remove) to children
orphanRemoval Delete children when removed from parent’s collection
FetchType.LAZY Load related entity only when accessed (recommended default)
FetchType.EAGER Load related entity immediately with parent (avoid)
N+1 Problem 1 query for parents + N queries for children = slow
JOIN FETCH JPQL keyword to load related entities in a single query
@EntityGraph Declarative eager loading for specific queries
@BatchSize Load related entities in batches instead of one by one
Set for ManyToMany Efficient add/remove operations on junction table
Helper methods Keep both sides of bidirectional relationships in sync

Leave a Reply

Your email address will not be published. Required fields are marked *