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
booktable gets apublisher_idcolumn. - 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.
@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 ofmappedBy 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 withspring.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);
}
}
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
bookhas the columnspublisher_idanddetails_id, from the@JoinColumnnames: the foreign keys of the many-to-one and one-to-one relationships, followed byalter tablestatements that add the foreign key constraints. The one-to-one relationship also got auniqueconstraint ondetails_id, so two books cannot share one set of details.book_authoris the join table from@JoinTable, with the two foreign keysbook_idandauthor_id. ASetof authors means a pair can appear only once.publisherandauthorhave no column for their side of the relationship. The inverse sides, marked withmappedBy, 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.ALLonPublisher.booksandBook.detailsmeans that operations on the parent are applied to the children. Saving the two publishers saved their three books and, through the books, their threeBookDetails- without a singlebookRepository.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()andb.getAuthors()just work: Hibernate loads what you touch. Doing the same outside a transaction fails for lazy relationships with aLazyInitializationException- the next tutorial explains why. - Queries across relationships:
findByPublisherNamejoined to the publisher,findByAuthorsNamewent through the join table, andfindByDetailsPagesGreaterThanlooked 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 frompublisher.getBooks()made it an orphan, and Hibernate deleted its row when the transaction committed - the list of books afterwards no longer contains it. Nodeletecall 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 topublisher.getBooks()but forgetbook.setPublisher(publisher), the in-memory list looks right and the database row gets nopublisher_id. That is whyaddBook()andaddAuthor()update both sides: always change a bidirectional relationship through such methods. - Choose the direction deliberately. A
@ManyToOneon the child alone, with no@OneToManyon the parent, is a perfectly good - often better - mapping: it is simpler, and the parent's list can be had withfindByPublisherNameor 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.REMOVEon a many-to-many relationship would delete the books of an author when the author is deleted - almost never what you want. toString,equalsandhashCodemust not follow relationships. AtoStringthat printsbooksinPublisherandpublisherinBookcalls itself forever and ends in aStackOverflowError- and Lombok's@Dataand@ToStringgenerate 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
Setfor many-to-many. Removing one element from aListmapped as many-to-many makes Hibernate delete all the join-table rows of that owner and insert the rest again. ASetdeletes only the one row. - Do not fetch more than needed. The defaults - lazy for collections, eager for
@ManyToOneand@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 nomappedBy) makes Hibernate create a join table and extra statements; add@JoinColumnon 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
@ManyToOneto each side. - Shared primary key:
@OneToOnewith@MapsIdmakes 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,
@Embeddableand@Embeddedmap 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.Spring Data Tutorials
Spring Data JPA @Query: JPQL and Native QueriesClick to read tutorial: Spring Data JPA @Query: JPQL and Native Queries JPA Lazy Loading and the N+1 Problem in Spring BootClick to read tutorial: JPA Lazy Loading and the N+1 Problem in Spring Boot Spring Data JPA Introduction: Entities and RepositoriesClick to read tutorial: Spring Data JPA Introduction: Entities and Repositories Spring Data JPA Derived Query Methods ExplainedClick to read tutorial: Spring Data JPA Derived Query Methods Explained Spring @Transactional: Propagation and IsolationClick to read tutorial: Spring @Transactional: Propagation and Isolation Spring Data JPA Projections and DTOsClick to read tutorial: Spring Data JPA Projections and DTOs Spring Data JPA Auditing: @CreatedDate and @CreatedByClick to read tutorial: Spring Data JPA Auditing: @CreatedDate and @CreatedBy
Spring Data JPA @Query: JPQL and Native QueriesClick to read tutorial: Spring Data JPA @Query: JPQL and Native Queries JPA Lazy Loading and the N+1 Problem in Spring BootClick to read tutorial: JPA Lazy Loading and the N+1 Problem in Spring Boot Spring Data JPA Introduction: Entities and RepositoriesClick to read tutorial: Spring Data JPA Introduction: Entities and Repositories Spring Data JPA Derived Query Methods ExplainedClick to read tutorial: Spring Data JPA Derived Query Methods Explained Spring @Transactional: Propagation and IsolationClick to read tutorial: Spring @Transactional: Propagation and Isolation Spring Data JPA Projections and DTOsClick to read tutorial: Spring Data JPA Projections and DTOs Spring Data JPA Auditing: @CreatedDate and @CreatedByClick to read tutorial: Spring Data JPA Auditing: @CreatedDate and @CreatedBy