@OneToMany & @ManyToOne
A one-to-many relationship is the most common association in JPA: one parent row owns many child rows, such as one Author with many Book rows. In a bidirectional mapping the @ManyToOne side is the owning side that holds the foreign key, and the @OneToMany(mappedBy = "...") side is the inverse side. Getting the ownership right is what keeps your schema clean and your saves correct. For a refresher on the basics, see Entity Mapping.
Owning vs inverse side
JPA needs exactly one side to manage the foreign key. The side that physically stores the FK column is the owning side — here, Book, because each book row carries an author_id. Hibernate only reads the owning side when deciding what SQL to run, so the FK lives there with @JoinColumn.
The @OneToMany collection is the inverse side. By setting mappedBy = "author" you tell Hibernate “this collection is already mapped by the author field on Book”. Without mappedBy, Hibernate assumes the two sides are unrelated and creates a redundant join table to link them.
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Entity
public class Author {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(
mappedBy = "author", // inverse side: FK lives on Book.author
cascade = CascadeType.ALL, // persist/remove children with the parent
orphanRemoval = true // delete a Book when removed from this list
)
private List<Book> books = new ArrayList<>();
// helper methods keep BOTH sides in sync
public void addBook(Book book) {
books.add(book);
book.setAuthor(this); // sets the FK on the owning side
}
public void removeBook(Book book) {
books.remove(book);
book.setAuthor(null); // with orphanRemoval, triggers DELETE
}
protected Author() { }
public Author(String name) { this.name = name; }
// getters and setters
}
import jakarta.persistence.*;
@Entity
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToOne(fetch = FetchType.LAZY) // owning side
@JoinColumn(name = "author_id") // the actual FK column
private Author author;
protected Book() { }
public Book(String title) { this.title = title; }
// getters and setters
}
Note: Default
@ManyToOnefetch isEAGER, which silently triggers extra queries. Almost always setfetch = FetchType.LAZY— see Fetch Types.
Why helper methods matter
In a bidirectional relationship the object graph and the database can drift apart. If you only add a Book to author.getBooks() but never call book.setAuthor(author), the owning side stays null and Hibernate writes a NULL foreign key. The addBook/removeBook helpers update both references in one place so this can never happen.
Author author = new Author("Ursula K. Le Guin");
author.addBook(new Book("A Wizard of Earthsea"));
author.addBook(new Book("The Left Hand of Darkness"));
authorRepository.save(author); // CascadeType.ALL inserts both books too
Generated SQL
With CascadeType.ALL, saving the parent cascades to the children. The FK column appears on the child table only — no join table.
create table author (
id bigint generated by default as identity,
name varchar(255),
primary key (id)
);
create table book (
id bigint generated by default as identity,
title varchar(255),
author_id bigint,
primary key (id),
constraint fk_book_author
foreign key (author_id) references author (id)
);
Saving the author above produces:
insert into author (name) values ('Ursula K. Le Guin');
insert into book (title, author_id) values ('A Wizard of Earthsea', 1);
insert into book (title, author_id) values ('The Left Hand of Darkness', 1);
Notice the author_id is set on the INSERT into book because the owning side (Book.author) was populated by the helper method.
Cascade and orphanRemoval
Cascade propagates entity-manager operations from parent to children. orphanRemoval is different: it deletes a child as soon as it is disassociated from the parent collection, even if you never call delete().
| Feature | What it does | Typical use |
|---|---|---|
cascade = PERSIST | Saving parent saves new children | Insert parent + children together |
cascade = ALL | All operations (persist, merge, remove…) cascade | Parent fully owns children’s lifecycle |
orphanRemoval = true | Removing child from collection issues DELETE | Children cannot exist without parent |
| (none) | Children managed independently | Shared / long-lived child entities |
Author author = authorRepository.findById(1L).orElseThrow();
Book first = author.getBooks().get(0);
author.removeBook(first); // orphanRemoval -> DELETE from book where id = ?
authorRepository.save(author);
delete from book where id = 1;
Tip: Use
orphanRemoval = trueonly for true parent-child ownership (anOrderItemhas no meaning without itsOrder). For shared entities it can delete rows you did not intend to.
Owning vs inverse side at a glance
| Aspect | Owning side (@ManyToOne) | Inverse side (@OneToMany) |
|---|---|---|
| Holds the FK column | Yes (@JoinColumn) | No |
Declares mappedBy | No | Yes (points to owning field) |
| Drives the SQL | Yes — changes here are persisted | No — changes alone are ignored |
| Default fetch | EAGER (override to LAZY) | LAZY |
| Lives on | The “many” entity (Book) | The “one” entity (Author) |
Common pitfalls
- Forgetting
mappedBycreates an unwanted join table and double-writes the relationship. Always set it on the@OneToManyside. - Updating only the inverse side leaves the FK
NULL. Use helper methods. LazyInitializationExceptionwhen iteratingauthor.getBooks()outside a transaction — fetch the collection inside a@Transactionalmethod or with aJOIN FETCHquery. See Transactions.- Loading children in a loop is the classic N+1 problem; batch-fetch or join instead.
EAGERcollections load every child on every query — keep collectionsLAZY.