Blog

One To Many Example | Spring Data JPA

Key Takeaways

  • Prefer a bidirectional Author/Book mapping when you need navigation in both directions: Book.author owns the relationship, while Author.books uses mappedBy.
  • The child foreign key avoids the link table used by the default unidirectional @OneToMany mapping. Bidirectionality alone does not guarantee faster queries.
  • Use helper methods to update both Java references; changing only the inverse collection is not enough.
  • Choose cascades and orphan removal from your lifecycle requirements, not from a copy-and-paste example.
  • Plan collection fetching explicitly and test persistence behavior after flushing and clearing the persistence context.

A Spring Data JPA one to many relationship usually works well as a bidirectional @OneToMany/@ManyToOne association backed by a foreign key on the child table. For Author and Book, Book.author controls that foreign key, and Author.books provides the reverse navigation. This is a practical default—not a promise of fewer queries or better read performance.

How @OneToMany Works Under the Hood

Three layers are involved: Jakarta Persistence defines mapping and ownership rules; Hibernate implements them and generates SQL; Spring Data JPA supplies repositories and query integration. If those boundaries are unclear, start with Spring Data JPA vs Hibernate.

The examples use jakarta.persistence imports. Older javax.persistence projects need the imports and dependencies appropriate to their framework generation; see Spring's Jakarta migration guidance.

Ownership and mappedBy

mappedBy = "author" names the Java association field Book.author—not the database column author_id. Book.author is the owning side, so its value determines the foreign-key relationship. Author.books is the inverse side; adding a book only to that collection does not establish the owning reference.

Java references:
Author.books -- inverse, mappedBy="author" --> Book
Author       <----------- owner: Book.author -- Book

Database relationship:
book.author_id -- foreign key --> author.id

A persistence context tracks managed entities. Hibernate detects changes and synchronizes them with the database during a flush; SQL need not run at each helper-method call. Changes to ordinary fields on managed entities use dirty checking, not cascade settings.

Mapping defaults worth knowing

These are the Jakarta Persistence 3.2 defaults; the examples make important choices explicit rather than depending on them.

SettingDefault
@OneToMany fetchingLAZY
@ManyToOne fetchingEAGER
Cascaded operationsNone
orphanRemovalfalse

See the @OneToMany API and @ManyToOne API. LAZY is a provider hint, not a guarantee about exactly when or how SQL executes.

Bidirectional @OneToMany Example: Preferred Default

Set up a runnable Spring Data JPA project

Follow Spring's JPA getting-started setup: generate a Maven Java project with Spring Data JPA and H2 Database. Choose a stable Spring Boot release compatible with your JDK, let Boot manage dependency versions, and include spring-boot-starter-test for the test below.

Use com.example.library as the package, keep the generated @SpringBootApplication class there, and place each Java class below in its own file under src/main/java/com/example/library. For a longer setup walkthrough, see the Spring Data JPA quick start.

Create src/main/resources/application.yaml:

spring:
  datasource:
    url: jdbc:h2:mem:library
  sql:
    init:
      mode: always
  jpa:
    hibernate:
      ddl-auto: validate

Create src/main/resources/schema.sql. Boot's database initialization runs this script before JPA initialization by default; validate checks the mapping against the existing schema rather than creating tables.

create table author (
    id bigint generated by default as identity primary key,
    name varchar(255) not null
);

create table book (
    id bigint generated by default as identity primary key,
    title varchar(255) not null,
    author_id bigint not null,
    constraint fk_book_author foreign key (author_id) references author(id)
);

create index ix_book_author on book(author_id);

This uses H2's identity-column syntax. The foreign key enforces the parent reference, NOT NULL requires every stored book to have an author, and the index is an explicit choice for author-based book lookups.

Author.java: keep both sides synchronized

This example treats a book as privately owned by one author: removing it means deleting it, not transferring it. That lifecycle assumption explains orphanRemoval = true.

package com.example.library;

import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;

@Entity
@Table(name = "author")
public class Author {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @OneToMany(mappedBy = "author",
        cascade = {CascadeType.PERSIST, CascadeType.MERGE},
        orphanRemoval = true)
    private List<Book> books = new ArrayList<>();

    protected Author() {}

    public Author(String name) {
        this.name = Objects.requireNonNull(name);
    }

    public Long getId() { return id; }
    public String getName() { return name; }

    public List<Book> getBooks() {
        return Collections.unmodifiableList(books);
    }

    public void addBook(Book book) {
        Objects.requireNonNull(book);
        if (books.contains(book)) return;
        if (book.getAuthor() != null) {
            throw new IllegalArgumentException("Book already has an author");
        }
        books.add(book);
        book.assignAuthor(this);
    }

    public void removeBook(Book book) {
        if (books.remove(book)) {
            book.assignAuthor(null);
        }
    }
}

Book.java: own the foreign key

package com.example.library;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.util.Objects;

@Entity
@Table(name = "book")
public class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String title;

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

    protected Book() {}

    public Book(String title) {
        this.title = Objects.requireNonNull(title);
    }

    public Long getId() { return id; }
    public String getTitle() { return title; }
    public Author getAuthor() { return author; }

    void assignAuthor(Author author) {
        this.author = author;
    }
}

The protected no-argument constructors support persistence-provider instantiation. Field annotations let the provider access persistent fields directly; public setters aren't required here. Call the helpers instead of bypassing them.

Persisting and retrieving through a repository

AuthorRepository.java deliberately offers a query that includes books in its fetch plan:

package com.example.library;

import java.util.Optional;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;

public interface AuthorRepository extends JpaRepository<Author, Long> {
    @EntityGraph(attributePaths = "books")
    Optional<Author> findWithBooksById(Long id);
}

Spring Data derives the lookup from ById; WithBooks is descriptive text in the method's subject. The @EntityGraph annotation, not that descriptive text, configures fetching.

LibraryService.java supplies transaction boundaries and returns values rather than a lazy entity graph:

package com.example.library;

import jakarta.persistence.EntityNotFoundException;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class LibraryService {
    private final AuthorRepository authors;

    public LibraryService(AuthorRepository authors) {
        this.authors = authors;
    }

    @Transactional
    public Long createAuthor() {
        Author author = new Author("Example Author");
        author.addBook(new Book("First Book"));
        author.addBook(new Book("Second Book"));
        return authors.save(author).getId();
    }

    @Transactional(readOnly = true)
    public List<String> bookTitles(Long authorId) {
        Author author = authors.findWithBooksById(authorId)
            .orElseThrow(() ->
                new EntityNotFoundException("Author " + authorId));
        return author.getBooks().stream().map(Book::getTitle).toList();
    }
}

Invoke these methods through the injected Spring service bean so transaction interception applies. Spring Data's save operation chooses persist or merge according to whether it considers the entity new; PERSIST handles the new books in this example. For basic repository operations, see the Spring Data JPA CRUD example.

Illustrative Hibernate SQL

The following are schematic Hibernate 7.1/H2 SQL shapes for these mappings, not captured logs or guaranteed statement counts. Identifier generation, batching, flush timing, fetch plans, and dialect affect actual output.

-- Illustrative parameterized SQL, not an executable script.
insert into author (name) values (?);
insert into book (author_id, title) values (?, ?);
insert into book (author_id, title) values (?, ?);

-- Possible separate lookups with findById and lazy initialization:
select id, name from author where id = ?;
select id, title, author_id from book where author_id = ?;

-- A joined fetch can instead have this shape:
select a.id, a.name, b.id, b.title, b.author_id
from author a left join book b on b.author_id = a.id
where a.id = ?;

The stable fact is the schema: this relationship uses book.author_id, with no association table. The separate-select example is not a prediction of the entity-graph method's SQL.

Cascades and Orphan Removal Are Lifecycle Decisions

The CascadeType API distinguishes operations:

  • PERSIST propagates persistence to associated new entities.
  • MERGE propagates merging of entity state.
  • REMOVE propagates deletion when the parent is removed.
  • REFRESH propagates reloading from the database.
  • DETACH propagates detachment from the persistence context.
  • ALL includes all five, including REMOVE.

For a managed relationship, orphanRemoval = true applies removal to a child taken out of the collection at flush. It also propagates removal when the parent is removed, so explicitly adding CascadeType.REMOVE isn't necessary for that relationship. Without orphan removal, ordinary dissociation does not itself delete the book.

Test helper synchronization and orphan deletion

Place AuthorMappingTest.java under src/test/java/com/example/library. This integration test checks both Java references, then verifies deletion after flush and clear rather than trusting the in-memory collection alone.

package com.example.library;

import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
@Transactional
class AuthorMappingTest {
    @Autowired AuthorRepository authors;
    @Autowired EntityManager em;

    @Test
    void removingBookDeletesTheOrphan() {
        Author author = new Author("Example Author");
        Book removed = new Book("First Book");
        Book retained = new Book("Second Book");
        author.addBook(removed);
        author.addBook(retained);
        assertSame(author, removed.getAuthor());
        assertSame(author, retained.getAuthor());

        authors.saveAndFlush(author);
        Long authorId = author.getId();
        Long removedId = removed.getId();

        author.removeBook(removed);
        assertNull(removed.getAuthor());
        assertFalse(author.getBooks().contains(removed));

        em.flush();
        em.clear();

        assertNull(em.find(Book.class, removedId));
        assertEquals(1, authors.findWithBooksById(authorId)
            .orElseThrow().getBooks().size());
    }
}

Run the test with the generated Maven wrapper:

./mvnw test

Unidirectional @OneToMany Example

The default uses a join table

Without mappedBy or a foreign-key override, a unidirectional @OneToMany uses a join table. Book has no author reference. The following replacement fragment makes the join-table names explicit; it is not an addition to the runnable mapping.

// Fragment inside the alternative Author class.
// Imports: jakarta.persistence.* and java.util.*.
@OneToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinTable(name = "author_books",
    joinColumns = @JoinColumn(name = "author_id"),
    inverseJoinColumns = @JoinColumn(name = "book_id", unique = true))
private List<Book> books = new ArrayList<>();

public void addBook(Book book) {
    Objects.requireNonNull(book);
    if (!books.contains(book)) books.add(book);
}

public void removeBook(Book book) {
    books.remove(book);
}

Remove Book.author and its accessor methods for this alternative. Replace the schema with author, book, and author_books tables; book no longer needs author_id. The unique book_id constraint on the link table prevents a book from belonging to multiple authors.

Persist with the same addBook and authors.save pattern. Retrieve with findById inside a transaction and access getBooks, or reuse the repository's explicit fetch plan.

-- Illustrative Hibernate 7.1/H2 SQL shapes; not an executable script.
insert into author (name) values (?);
insert into book (title) values (?);
insert into book (title) values (?);
insert into author_books (author_id, book_id) values (?, ?);
insert into author_books (author_id, book_id) values (?, ?);

-- Possible collection initialization through the link table:
select b.id, b.title
from author_books ab join book b on b.id = ab.book_id
where ab.author_id = ?;

This can be a valid model when navigation is only from author to books. It adds link rows and changes collection-write behavior; some Hibernate collection mappings may delete and recreate links during removal. Inspect your actual SQL instead of assuming a universal performance penalty.

Unidirectional @OneToMany with @JoinColumn

A unidirectional collection can also use a child foreign key. Replace the @JoinTable mapping with this fragment:

// Alternative Author field; Book still has no author reference.
// Imports: jakarta.persistence.* and java.util.*.
@OneToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinColumn(name = "author_id")
private List<Book> books = new ArrayList<>();

Here the collection owns book.author_id. The @JoinColumn mapping avoids a link table without adding reverse Java navigation. Inserts and association updates can differ from the child-owned mapping; verify their ordering against your constraints. Because this fragment keeps dissociated books, its schema must permit a null author_id or the application must assign another author.

Why Omitting mappedBy Creates Two Associations

An Author.books collection without mappedBy plus a separate Book.author @ManyToOne does not describe one bidirectional relationship. It describes two independent associations:

Author.books --> author_books link table --> Book
Book.author  --> book.author_id foreign key --> Author

Adding a book to the collection doesn't set Book.author or justify an author_id value in its insert. The two associations can disagree. To represent one shared relationship, restore mappedBy = "author" and synchronize both sides; otherwise remove the back-reference and intentionally use a unidirectional mapping.

Fetch Planning, Lazy Loading, and Collections

Avoid N+1 queries deliberately

N+1 means a parent lookup followed by additional collection lookups for individual parents. A bidirectional mapping can still produce this pattern. Use the repository's entity graph or a JPQL fetch join when the use case needs books; an entity graph specifies required fetching, not an exact SQL statement count.

Collection joins multiply database result rows. Hibernate 7.1 removes duplicate root-entity results from fetch joins in memory, but collection-fetch pagination can still require loading matching rows and applying limits in memory. For paged author screens, prefer paging parent IDs first and fetching the required books separately, or use a projection. See the Hibernate query guide.

Respect the persistence-context boundary

Access uninitialized lazy collections while their persistence context is available, normally inside your service transaction. Hibernate can throw LazyInitializationException when unfetched data is accessed after the session closes. Materialize the response values before returning rather than relying on later serialization to load entities.

Choose List, Set, and ordering intentionally

This example uses List and reference equality within one persistence context. Work with the managed collection's Book instances instead of constructing ID-only stand-ins. It intentionally avoids presenting generated-ID equality as universally safe.

A Set depends on equals/hashCode. Don't base a changing hash code on a generated ID, or combine instanceof-based equality with proxy-dependent getClass hashes. Equality strategies must account for transient entities, detached instances, and Hibernate proxies.

A plain List doesn't persist insertion order. Use @OrderBy("title ASC") for retrieval ordering by an entity field, or @OrderColumn for stored list positions; don't combine them. Stored positions also require a corresponding schema column.

Which One-to-Many Mapping Should You Choose?

MappingRelationship storageOwning Java sideGood fit
Bidirectional, mappedByChild foreign keyBook.authorNavigation from both sides and straightforward child ownership
Unidirectional, default storageJoin tableAuthor.booksParent-only navigation with a separate association table
Unidirectional, @JoinColumnChild foreign keyAuthor.booksParent-only navigation without a link table

For this Author/Book model, prefer the bidirectional mapping when both navigation paths matter. Choose based on ownership, schema, and lifecycle first; compare equivalent queries and writes before making performance claims.

Production Checklist

  • Manage the production schema with migrations rather than copying the disposable H2 initialization setup.
  • Keep foreign-key nullability aligned with dissociation and deletion rules, and review indexes against real query plans.
  • Test insert, unlink, orphan deletion, and parent deletion against your production database engine.
  • For potentially huge book collections, query and page books directly instead of treating the entire collection as a convenient response object.

FAQ

Which side owns a bidirectional @OneToMany relationship in JPA?

The child-side @ManyToOne owns the relationship. In this example, Book.author determines book.author_id, while Author.books uses mappedBy = "author" to refer to that Java association field.

Does unidirectional @OneToMany always create a join table?

No. A join table is the default, but @JoinColumn on the collection can map a foreign key in the child table instead. That collection then owns the relationship without a child-side Java back-reference.

What is the difference between CascadeType.REMOVE and orphanRemoval?

CascadeType.REMOVE propagates removal of the parent to its associated children. orphanRemoval also deletes a managed child removed from the relationship at flush, and it propagates parent removal without requiring an explicit REMOVE cascade.

How do I fetch a one-to-many collection without an N+1 query?

Choose an explicit fetch plan for the use case, such as a Spring Data JPA @EntityGraph or a JPQL fetch join. Check the generated SQL and avoid blindly combining collection fetch joins with pagination.

Why is a lazy collection throwing LazyInitializationException?

Hibernate cannot initialize unfetched data after its session has closed. Fetch and consume the collection within the service's persistence context, then return materialized values or a DTO rather than an uninitialized entity graph.

Further Reading

Written by Sam Erickson

Software engineer. I write every article on StackChief myself. Author of Mastering the Tech Interview. More about me