JPA Entity Relationships: OneToOne, OneToMany, ManyToMany

Introduction

This tutorial shows how to map relationships between entities in JPA: a publisher with many books, a book with one set of details, and books and authors that belong to each other in a many-to-many way. We will start with how relational databases express relationships, and the four JPA annotations that map them. We will then build the model - @ManyToOne and @OneToMany, @OneToOne and @ManyToMany - and look at the tables Hibernate creates for it. Next we will save a whole object graph with cascading, read it back, and query across relationships. We will then see orphanRemoval delete a book that was only removed from a list, and a delete cascade from publisher to books. Lastly, we will go through the mistakes that cause most JPA bugs: the owning side, out-of-sync sides, toString loops, and collection types.

(This tutorial builds on Spring Data JPA Introduction: Entities and RepositoriesClick to read tutorial: Spring Data JPA Introduction: Entities and Repositories and derived query methodsClick to read tutorial: Spring Data JPA Derived Query Methods Explained. When the related data is loaded, and the N+1 problem, are the subject of the next tutorial.)

Relationships in a database and in JPA

In a relational database, a relationship is a foreign key: a column in one table holding the id of a row in another. Three shapes cover almost everything -
  • Many-to-one (and its mirror image, one-to-many): many books have one publisher. The book table gets a publisher_id column.
  • One-to-one: each book has exactly one set of details. One table holds a foreign key to the other.
  • Many-to-many: a book has several authors, and an author wrote several books. This needs a third table, a join table, with a pair of foreign keys.
JPA maps these with @ManyToOne, @OneToMany, @OneToOne and @ManyToMany on fields that hold the related object or a collection of them - so in Java you navigate with book.getPublisher().getName(), and Hibernate does the joins.

The model

The model has four entities. Read the comments: the placement of mappedBy decides which side owns each relationship, which is the most important idea in this tutorial -
Publisher.java - one publisher, many books
//Publisher.java
package com.javabrahman.data.relations;

import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Publisher {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  private String name;

  // The inverse side: "publisher" in Book holds the foreign key; mappedBy names that field
  @OneToMany(mappedBy = "publisher", cascade = CascadeType.ALL, orphanRemoval = true)
  private List<Book> books = new ArrayList<>();

  protected Publisher() {
  }

  public Publisher(String name) {
    this.name = name;
  }

  // Keep both sides in step: always change a relationship through these methods
  public void addBook(Book book) {
    books.add(book);
    book.setPublisher(this);
  }

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

  public Long getId() { return id; }
  public String getName() { return name; }
  public List<Book> getBooks() { return books; }

  @Override
  public String toString() {
    return "Publisher[" + name + "]";   // not the books: see "toString loops" below
  }
}
BookDetails.java - the details of one book
//BookDetails.java
package com.javabrahman.data.relations;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

@Entity
public class BookDetails {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  private String isbn;
  private int pages;

  protected BookDetails() {
  }

  public BookDetails(String isbn, int pages) {
    this.isbn = isbn;
    this.pages = pages;
  }

  public String getIsbn() { return isbn; }
  public int getPages() { return pages; }

  @Override
  public String toString() {
    return "ISBN " + isbn + ", " + pages + " pages";
  }
}
Author.java - the inverse side of the many-to-many
//Author.java
package com.javabrahman.data.relations;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToMany;
import java.util.HashSet;
import java.util.Set;

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

  @ManyToMany(mappedBy = "authors")   // inverse side: Book.authors owns the join table
  private Set<Book> books = new HashSet<>();

  protected Author() {
  }

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

  public String getName() { return name; }
  public Set<Book> getBooks() { return books; }

  @Override
  public String toString() {
    return name;
  }
}
Book.java - the owning side of all three relationships
//Book.java
package com.javabrahman.data.relations;

import jakarta.persistence.CascadeType;
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.JoinTable;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToOne;
import java.util.HashSet;
import java.util.Set;

@Entity
public class Book {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  private String title;

  // Many books, one publisher. The owning side: the foreign key column publisher_id is in the book table
  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "publisher_id")
  private Publisher publisher;

  // One book, one set of details. The foreign key details_id is in the book table
  @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true)
  @JoinColumn(name = "details_id")
  private BookDetails details;

  // Many books, many authors. The owning side defines the join table
  @ManyToMany
  @JoinTable(name = "book_author",
      joinColumns = @JoinColumn(name = "book_id"),
      inverseJoinColumns = @JoinColumn(name = "author_id"))
  private Set<Author> authors = new HashSet<>();

  protected Book() {
  }

  public Book(String title, BookDetails details) {
    this.title = title;
    this.details = details;
  }

  public void addAuthor(Author author) {
    authors.add(author);
    author.getBooks().add(this);
  }

  public Long getId() { return id; }
  public String getTitle() { return title; }
  public Publisher getPublisher() { return publisher; }
  public void setPublisher(Publisher publisher) { this.publisher = publisher; }
  public BookDetails getDetails() { return details; }
  public Set<Author> getAuthors() { return authors; }

  @Override
  public String toString() {
    return title;
  }
}
PublisherRepository.java
//PublisherRepository.java
package com.javabrahman.data.relations;

import org.springframework.data.jpa.repository.JpaRepository;

public interface PublisherRepository extends JpaRepository<Publisher, Long> {
}
AuthorRepository.java
//AuthorRepository.java
package com.javabrahman.data.relations;

import org.springframework.data.jpa.repository.JpaRepository;

public interface AuthorRepository extends JpaRepository<Author, Long> {
}
BookRepository.java - derived queries across relationships
//BookRepository.java
package com.javabrahman.data.relations;

import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;

public interface BookRepository extends JpaRepository<Book, Long> {
  List<Book> findByPublisherName(String name);              // follows book.publisher.name
  List<Book> findByAuthorsName(String name);                // follows book.authors.name: a join through book_author
  List<Book> findByDetailsPagesGreaterThan(int pages);      // follows book.details.pages
}

The tables Hibernate creates

Starting the application with spring.jpa.show-sql=true, here are the statements that create the schema from these four entities -
RelationsApp.java - builds an object graph, reads it back and queries across the relationships
//RelationsApp.java
package com.javabrahman.data.relations;

import java.util.List;
import java.util.stream.Collectors;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.transaction.support.TransactionTemplate;

@SpringBootApplication
public class RelationsApp {

  @Bean
  CommandLineRunner demo(PublisherRepository publishers, BookRepository books, AuthorRepository authors, TransactionTemplate tx) {
    return args -> {
      System.out.println("== save a whole graph");
      tx.executeWithoutResult(status -> {
        Author bloch = new Author("Joshua Bloch");
        Author goetz = new Author("Brian Goetz");
        Author peierls = new Author("Tim Peierls");
        Author urma = new Author("Raoul-Gabriel Urma");
        Author fusco = new Author("Mario Fusco");

        Book effective = new Book("Effective Java", new BookDetails("9780134685991", 412));
        effective.addAuthor(bloch);
        Book concurrency = new Book("Java Concurrency in Practice", new BookDetails("9780321349606", 384));
        concurrency.addAuthor(goetz);
        concurrency.addAuthor(peierls);
        Book modern = new Book("Modern Java in Action", new BookDetails("9781617293566", 592));
        modern.addAuthor(urma);
        modern.addAuthor(fusco);

        Publisher addisonWesley = new Publisher("Addison-Wesley");
        addisonWesley.addBook(effective);
        addisonWesley.addBook(concurrency);
        Publisher manning = new Publisher("Manning");
        manning.addBook(modern);

        authors.saveAll(List.of(bloch, goetz, peierls, urma, fusco));   // authors are not cascaded to
        publishers.saveAll(List.of(addisonWesley, manning));            // saves the books and their details too
      });
      System.out.println("publishers " + publishers.count() + ", books " + books.count() + ", authors " + authors.count());

      System.out.println("== read, navigating the relationships (inside one transaction)");
      tx.executeWithoutResult(status -> books.findAll().forEach(b ->
          System.out.println(b + " | published by " + b.getPublisher() + " | " + b.getDetails() + " | by "
              + b.getAuthors().stream().map(Author::getName).sorted().collect(Collectors.joining(", ")))));

      System.out.println("== queries across relationships");
      System.out.println("findByPublisherName(\"Manning\"): " + books.findByPublisherName("Manning"));
      System.out.println("findByAuthorsName(\"Brian Goetz\"): " + books.findByAuthorsName("Brian Goetz"));
      System.out.println("findByDetailsPagesGreaterThan(400): " + books.findByDetailsPagesGreaterThan(400));

      System.out.println("== orphanRemoval: remove a book from its publisher's list");
      tx.executeWithoutResult(status -> {
        Publisher p = publishers.findAll().stream().filter(x -> x.getName().equals("Addison-Wesley")).findFirst().orElseThrow();
        Book first = p.getBooks().get(0);
        System.out.println("removing " + first + " from " + p);
        p.removeBook(first);
      });
      System.out.println("books now: " + books.findAll());

      System.out.println("== cascade remove: delete a publisher");
      tx.executeWithoutResult(status -> publishers.findAll().stream()
          .filter(x -> x.getName().equals("Manning")).findFirst().ifPresent(publishers::delete));
      System.out.println("publishers " + publishers.count() + ", books " + books.count() + ", authors " + authors.count());
    };
  }

  public static void main(String[] args) {
    SpringApplication.run(RelationsApp.class, args);
  }
}
 OUTPUT - the DDL statements generated from the four entities
Hibernate: create table author (id bigint generated by default as identity, name varchar(255), primary key (id))
Hibernate: create table book (details_id bigint unique, id bigint generated by default as identity, publisher_id bigint, title varchar(255), primary key (id))
Hibernate: create table book_author (author_id bigint not null, book_id bigint not null, primary key (author_id, book_id))
Hibernate: create table book_details (pages integer not null, id bigint generated by default as identity, isbn varchar(255), primary key (id))
Hibernate: create table publisher (id bigint generated by default as identity, name varchar(255), primary key (id))
Hibernate: alter table if exists book add constraint FKpdelws1h5lfe8hq82wgf2d8y5 foreign key (details_id) references book_details
Hibernate: alter table if exists book add constraint FKgtvt7p649s4x80y6f4842pnfq foreign key (publisher_id) references publisher
Hibernate: alter table if exists book_author add constraint FKbjqhp85wjv8vpr0beygh6jsgo foreign key (author_id) references author
Hibernate: alter table if exists book_author add constraint FKhwgu59n9o80xv75plf9ggj7xn foreign key (book_id) references book

Explanation of the tables

  • book has the columns publisher_id and details_id, from the @JoinColumn names: the foreign keys of the many-to-one and one-to-one relationships, followed by alter table statements that add the foreign key constraints. The one-to-one relationship also got a unique constraint on details_id, so two books cannot share one set of details.
  • book_author is the join table from @JoinTable, with the two foreign keys book_id and author_id. A Set of authors means a pair can appear only once.
  • publisher and author have no column for their side of the relationship. The inverse sides, marked with mappedBy, are only a convenient way to navigate the relationship from the other end; the database already has everything in the owning side's foreign keys.

Saving, reading and querying the graph

 OUTPUT of the above code
== save a whole graph
publishers 2, books 3, authors 5
== read, navigating the relationships (inside one transaction)
Effective Java | published by Publisher[Addison-Wesley] | ISBN 9780134685991, 412 pages | by Joshua Bloch
Java Concurrency in Practice | published by Publisher[Addison-Wesley] | ISBN 9780321349606, 384 pages | by Brian Goetz, Tim Peierls
Modern Java in Action | published by Publisher[Manning] | ISBN 9781617293566, 592 pages | by Mario Fusco, Raoul-Gabriel Urma
== queries across relationships
findByPublisherName("Manning"): [Modern Java in Action]
findByAuthorsName("Brian Goetz"): [Java Concurrency in Practice]
findByDetailsPagesGreaterThan(400): [Effective Java, Modern Java in Action]
== orphanRemoval: remove a book from its publisher's list
removing Effective Java from Publisher[Addison-Wesley]
books now: [Java Concurrency in Practice, Modern Java in Action]
== cascade remove: delete a publisher
publishers 1, books 1, authors 5

Explanation of the output

  • Cascading: cascade = CascadeType.ALL on Publisher.books and Book.details means that operations on the parent are applied to the children. Saving the two publishers saved their three books and, through the books, their three BookDetails - without a single bookRepository.save(). The authors had no cascade pointing at them, so they were saved explicitly first: saving a book that points to an unsaved author would fail.
  • Navigating: inside one transaction, b.getPublisher(), b.getDetails() and b.getAuthors() just work: Hibernate loads what you touch. Doing the same outside a transaction fails for lazy relationships with a LazyInitializationException - the next tutorial explains why.
  • Queries across relationships: findByPublisherName joined to the publisher, findByAuthorsName went through the join table, and findByDetailsPagesGreaterThan looked at a field of the related details - all derived from the path in the method name, and each with a join in its SQL.
  • orphanRemoval = true: removing a book from publisher.getBooks() made it an orphan, and Hibernate deleted its row when the transaction committed - the list of books afterwards no longer contains it. No delete call was made.
  • Cascade remove: deleting the Manning publisher deleted its book too - one book is left, and one publisher. The authors were not touched: nothing cascades to them, so all five are still there.

The rules that prevent most relationship bugs

  • Only the owning side writes to the database. The side without mappedBy - Book.publisher, Book.authors - is the one Hibernate looks at when saving. If you add a book to publisher.getBooks() but forget book.setPublisher(publisher), the in-memory list looks right and the database row gets no publisher_id. That is why addBook() and addAuthor() update both sides: always change a bidirectional relationship through such methods.
  • Choose the direction deliberately. A @ManyToOne on the child alone, with no @OneToMany on the parent, is a perfectly good - often better - mapping: it is simpler, and the parent's list can be had with findByPublisherName or a query when it is needed. Add the collection only if you really navigate from parent to children, and a huge list of children is a reason not to.
  • Cascade only down real ownership. A publisher owns its books' lifecycle in this model; an author does not own books, so no cascade there. CascadeType.REMOVE on a many-to-many relationship would delete the books of an author when the author is deleted - almost never what you want.
  • toString, equals and hashCode must not follow relationships. A toString that prints books in Publisher and publisher in Book calls itself forever and ends in a StackOverflowError - and Lombok's @Data and @ToString generate exactly that. The same loop happens when a JSON converter serialises both sides: return DTOs from your APIs instead of entities (see Spring Data JPA Projections and DTOsClick to read tutorial: Spring Data JPA Projections and DTOs).
  • Use Set for many-to-many. Removing one element from a List mapped as many-to-many makes Hibernate delete all the join-table rows of that owner and insert the rest again. A Set deletes only the one row.
  • Do not fetch more than needed. The defaults - lazy for collections, eager for @ManyToOne and @OneToOne - are not always the best choice; this is the topic of the next tutorial.

Variations you will meet

  • Unidirectional @OneToMany (a collection with no matching @ManyToOne, and no mappedBy) makes Hibernate create a join table and extra statements; add @JoinColumn on the collection to use a foreign key in the child table instead.
  • A join table with its own data - a book's role for each author, such as "editor" - cannot be a plain many-to-many. Model it as an entity of its own, with a @ManyToOne to each side.
  • Shared primary key: @OneToOne with @MapsId makes the child use its parent's id as its own, and needs no separate foreign key column.
  • Embedded values: for data that has no identity of its own, such as an address, @Embeddable and @Embedded map an object's fields into the owner's own table - no relationship needed.

Summary

In this tutorial we first saw how relationships are foreign keys and a join table in a database, and the four annotations that map them. We built a model with @ManyToOne and @OneToMany between publishers and books, @OneToOne between a book and its details, and @ManyToMany between books and authors, and looked at the tables and foreign keys Hibernate generated. We saved a whole graph through cascading, read it back, queried across the relationships with derived queries, and watched orphanRemoval and a cascading delete remove rows without a delete call. Lastly we went through the rules that prevent most bugs: the owning side, helper methods that update both sides, careful cascades, relationship-free toString and equals, and Set for many-to-many. The next tutorial explains when related data is loaded, and the performance trap hidden in it: lazy loading and the N+1 problemClick to read tutorial: JPA Lazy Loading and the N+1 Problem in Spring Boot.