Skip to content
CalliCoder

JPA One to Many Mapping Example with Spring Boot

Published Updated Spring Boot 12 min read

Bidirectional mapping done properly: which side owns the foreign key, the helper methods that keep both sides consistent, the infinite JSON recursion, and MultipleBagFetchException.

A one-to-many mapping is four annotations and a great deal of behaviour. The annotations are easy; what takes time is the join table that appears when you did not ask for one, the stack overflow when you serialise, and the N+1 that only shows up with real data.

Written against Spring Boot 3.2, Hibernate 6.4 and Java 17.

The two entities

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

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

    @Column(nullable = false)
    private String title;

    @OneToMany(
        mappedBy = "post",
        cascade = CascadeType.ALL,
        orphanRemoval = true,
        fetch = FetchType.LAZY
    )
    private Set<Comment> comments = new HashSet<>();

    // getters and setters
}
@Entity
@Table(name = "comments")
public class Comment {

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

    @Column(nullable = false, length = 2000)
    private String text;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "post_id", nullable = false)
    private Post post;

    // getters and setters
}

Every attribute above is doing something, and three of them are the ones people get wrong.

mappedBy = "post" says the other side owns the relationship. In a relational database the foreign key lives on the many side, and mappedBy tells JPA that Comment.post is the column that represents this association. Without it, JPA assumes the one side owns it and creates a join table: posts_comments, with two foreign keys, for a relationship that needs one column. That surprise is the single most common defect in this mapping.

fetch = FetchType.LAZY on @ManyToOne. The default for @ManyToOne is EAGER, which means loading any comment also loads its post, and loading a hundred comments issues a hundred extra queries unless Hibernate can join. Set it explicitly on every @ManyToOne. (@OneToMany defaults to LAZY already; writing it out costs nothing and documents the intent.)

orphanRemoval = true versus cascade = REMOVE. They sound similar and differ in an important way. CascadeType.REMOVE deletes the comments when the post is deleted. orphanRemoval deletes a comment when it is removed from the collection, the child cannot exist unowned. With optional = false on the @ManyToOne. That is the correct model: a comment without a post is meaningless.

Keep both sides consistent

The most common source of “why was nothing saved” is setting one side and not the other. In memory, these are two independent fields; only one of them maps to a column.

public void addComment(Comment comment) {
    comments.add(comment);
    comment.setPost(this);
}

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

Put these on Post, make the collection’s setter private or absent, and always go through them. Setting only post.getComments().add(c) leaves c.post null, and since post_id is the column that actually gets written, the insert fails the not-null constraint, or worse, with a nullable column, succeeds and stores an orphan.

equals and hashCode, because the collection is a Set

A HashSet needs both, and entity equality is genuinely awkward: the id is null before persist, so a naive equals based on the id changes behaviour mid-transaction.

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof Comment other)) return false;
    return id != null && id.equals(other.id);
}

@Override
public int hashCode() {
    return getClass().hashCode();   // constant: stable across persist
}

Two deliberate choices. equals returns false when the id is null, so two unsaved comments are never equal, which is correct. They are different objects. And hashCode is constant for the type, so an entity’s bucket does not change when the id is assigned. A constant hash makes a large HashSet degenerate to a list, which is acceptable because entity collections are small; an unstable hash loses elements, which never is.

instanceof rather than getClass() == because Hibernate hands you proxy subclasses.

Repositories

public interface PostRepository extends JpaRepository<Post, Long> {

    @Query("select p from Post p left join fetch p.comments where p.id = :id")
    Optional<Post> findByIdWithComments(@Param("id") Long id);

    @EntityGraph(attributePaths = "comments")
    List<Post> findByTitleContainingIgnoreCase(String fragment);
}

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

CommentRepository.findByPostId matters more than it looks. Loading a post’s comments through the collection loads all of them; a post with four thousand comments has no pagination. Query the child repository when the collection is large.

N+1, and why it is invisible until it is not

List<Post> posts = postRepository.findAll();      // 1 query
for (Post p : posts) {
    p.getComments().size();                        // 1 query each
}

Twenty posts, twenty-one queries. Three ways out:

// 1. join fetch — one query, but see MultipleBagFetchException below
@Query("select distinct p from Post p left join fetch p.comments")
List<Post> findAllWithComments();

// 2. an entity graph — same effect, declarative
@EntityGraph(attributePaths = "comments")
List<Post> findAll();

// 3. batch the lazy loads
@BatchSize(size = 25)
private Set<Comment> comments = new HashSet<>();

@BatchSize is the underrated one: it keeps the collection lazy and loads the pending ones in batches, turning twenty-one queries into two without changing any query.

distinct in the JPQL is needed because a join multiplies rows: a post with three comments comes back three times otherwise.

Two exceptions worth recognising

MultipleBagFetchException. Join-fetching two List collections at once throws immediately at startup:

org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags

A List without @OrderColumn is a bag, an unordered collection allowing duplicates, and fetching two produces a cartesian product Hibernate cannot de-duplicate. Using Set for the collections, as above, avoids it entirely. That is the main practical reason to prefer Set over List for entity collections.

LazyInitializationException. Touching a lazy collection after the session closes:

failed to lazily initialize a collection of role: Post.comments — no Session

The fix is to fetch what you need inside the transaction: join fetch, an entity graph, or mapping to a DTO in the query. The fix is not spring.jpa.open-in-view=true; that keeps the session open for the whole request so the error goes away and the N+1 stays, executing queries during JSON serialisation. It defaults to true and Spring Boot logs a warning about it, which is worth acting on:

spring.jpa.open-in-view=false

Serialising without infinite recursion

Return these entities from a controller and Jackson walks post → comments → post → comments until the stack ends:

com.fasterxml.jackson.databind.JsonMappingException: Infinite recursion (StackOverflowError)

Three options, in increasing order of how much I would recommend them:

// annotate the back reference
@JsonIgnore
@ManyToOne(fetch = FetchType.LAZY, optional = false)
private Post post;

// or declare the pair explicitly
@JsonManagedReference          // on Post.comments
@JsonBackReference             // on Comment.post

Both work and both leak persistence concerns into the API: the JSON shape is now a property of your entity mapping, and a lazy proxy that Jackson touches triggers a query mid-serialisation.

The option that actually solves it is not to serialise entities at all:

public record CommentView(Long id, String text) { }

public record PostView(Long id, String title, List<CommentView> comments) {
    static PostView of(Post p) {
        return new PostView(p.getId(), p.getTitle(),
                p.getComments().stream().map(c -> new CommentView(c.getId(), c.getText())).toList());
    }
}

A record per response. The API shape becomes explicit, recursion is impossible, and nothing is serialised that was not deliberately fetched.

Checking the schema

mysql> show create table comments;

CREATE TABLE `comments` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `text` varchar(2000) NOT NULL,
  `post_id` bigint NOT NULL,
  PRIMARY KEY (`id`),
  KEY `FKpost` (`post_id`),
  CONSTRAINT `FKpost` FOREIGN KEY (`post_id`) REFERENCES `posts` (`id`)
)

One foreign key column on the many side, and no posts_comments table. If you see a join table, mappedBy is missing.

Add an index on post_id: Hibernate creates one for the constraint on MySQL, but not every database does, and every query for a post’s comments filters on it.

Frequently asked questions

Why did JPA create a join table for my one-to-many?

mappedBy is missing, so JPA assumed the one side owns the relationship. Add mappedBy naming the @ManyToOne field on the child.

Which side is the owning side?

The many side. It holds the foreign key. The one side is always the inverse and must declare mappedBy.

What is the difference between cascade REMOVE and orphanRemoval?

REMOVE deletes children when the parent is deleted. orphanRemoval also deletes a child when it is removed from the collection.

Why is nothing saved when I add to the collection?

Only the owning side maps to a column. Set the child’s parent reference too, use helper methods so both sides always move together.

Should the collection be a Set or a List?

Set. Two List collections cannot be join-fetched together (MultipleBagFetchException), and a Set needs no @OrderColumn to behave predictably.

What causes MultipleBagFetchException?

Join-fetching two List collections in one query. Use Set, or fetch one collection per query.

How do I fix LazyInitializationException?

Fetch inside the transaction with join fetch, an @EntityGraph, or a projection. Enabling open-in-view hides the error and keeps the N+1.

How do I stop infinite recursion in JSON?

Best: return DTOs rather than entities. Otherwise @JsonIgnore on the back reference, or the @JsonManagedReference/@JsonBackReference pair.

What is the default fetch type?

LAZY for @OneToMany, EAGER for @ManyToOne. Override the second explicitly on every mapping.

How do I paginate a child collection?

You cannot page a mapped collection, query the child repository instead: findByPostId(id, pageable).

Where should I go next?

ElementCollection covers the case where the children are values rather than entities, and the Spring Boot REST API guide covers the layers around this mapping.