Entities & Relationships¶
Mapping a single table is easy. The trouble starts with relationships: which side "owns" a foreign key, what gets saved or deleted along with what, and when related rows are loaded. Get these right once and most JPA pain disappears.
The schema we are mapping¶
create table author (
id bigint generated by default as identity primary key,
name varchar(200) not null
);
create table book (
id bigint generated by default as identity primary key,
title varchar(300) not null,
isbn varchar(20) not null unique,
published integer,
author_id bigint not null references author(id),
version bigint not null default 0
);
One author has many books; each book has exactly one author. The foreign key lives in
book.author_id.
The mapping¶
@Entity
public class Book {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(nullable = false, unique = true)
private String isbn;
private Integer published;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "author_id")
private Author author;
@Version
private long version;
protected Book() { }
public Book(String title, String isbn, Integer published) { ... }
void setAuthor(Author author) { this.author = author; } // package-private: use Author.addBook
// getters...
}
@Entity
public class Author {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@OneToMany(mappedBy = "author", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Book> books = new ArrayList<>();
protected Author() { }
public Author(String name) { this.name = name; }
public void addBook(Book book) {
books.add(book);
book.setAuthor(this);
}
public void removeBook(Book book) {
books.remove(book);
book.setAuthor(null);
}
// getters...
}
The owning side¶
In a bidirectional relationship, only one side controls the foreign key: the side
without mappedBy. Here that is Book.author (the @ManyToOne), matching where the
column lives. Author.books with mappedBy = "author" is the inverse side — a
convenient view that Hibernate ignores when deciding what to write.
The practical consequence:
author.getBooks().add(book); // alone: book.author_id is NOT set
book.setAuthor(author); // this is what writes the foreign key
That is why addBook updates both sides. Keep the Java object graph consistent with what
will be in the database, and keep that logic in one helper method.
Cascades and orphan removal¶
cascade = CascadeType.ALL on Author.books means operations on the author flow to its
books: persisting a new author with three new books inserts all four rows; deleting the
author deletes the books.
orphanRemoval = true goes further: removing a book from the collection deletes it.
Cascade from parent to children that are truly owned (order → order lines, author →
books in this model). Do not cascade from a child to a shared parent (Book.author must
never have CascadeType.REMOVE — deleting one book would delete the author and every
other book).
Worked example: saving a graph¶
Author author = new Author("Ursula K. Le Guin");
author.addBook(new Book("A Wizard of Earthsea", "978-0547773742", 1968));
author.addBook(new Book("The Dispossessed", "978-0061054884", 1974));
authors.save(author); // one save; cascade inserts the books with author_id set
In this level's project, exactly this pattern seeds three authors with two books each
through authors.save(author) alone; no BookRepository.save is needed.
Fetch types¶
| Relationship | JPA default | Recommendation |
|---|---|---|
@ManyToOne, @OneToOne |
EAGER | Set LAZY explicitly |
@OneToMany, @ManyToMany |
LAZY | Keep LAZY |
EAGER does not mean "joined efficiently" — it means "always loaded, whether you need it
or not," often through extra queries. Make everything lazy and choose what to fetch per
use case with join fetches or entity graphs (lesson 03 and Level 3's N+1 lesson).
Many-to-many¶
A book can have many tags and a tag many books:
@ManyToMany
@JoinTable(name = "book_tag",
joinColumns = @JoinColumn(name = "book_id"),
inverseJoinColumns = @JoinColumn(name = "tag_id"))
private Set<Tag> tags = new HashSet<>();
The moment the link needs its own data (who tagged it, when), replace @ManyToMany with an
explicit BookTag entity and two @ManyToOnes. That happens more often than you expect,
so many teams start with the explicit entity.
Equality and hashCode¶
Entities in Sets and in the persistence context need equals/hashCode that stay
stable while the entity moves from transient to managed. Database-generated ids are null
before persist, so "equal if ids are equal" breaks for new entities in a HashSet. Two
sound options:
- Use a natural business key that never changes (the ISBN) for equality.
- Or use the id with a constant hashCode:
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Book other)) return false;
return id != null && id.equals(other.getId());
}
@Override
public int hashCode() {
return getClass().hashCode(); // constant: safe across the transient → managed transition
}
Use getId() on the other object, not other.id, because other may be a Hibernate
proxy whose fields are not initialized.
How It Actually Works¶
Lazy loading uses proxies. For a lazy @ManyToOne, Hibernate does not put a real
Author in book.author; it puts a generated subclass of Author holding only the id.
The first call to a getter other than getId() asks the persistence context to load the
row. If the context is closed by then, you get LazyInitializationException — the proxy
has nobody to ask. Lazy collections work the same way with Hibernate's PersistentBag/
PersistentSet wrappers instead of your ArrayList. (This is also why entity classes and
their getters must not be final.)
Flush order. At flush, Hibernate orders statements so constraints are respected:
inserts (parents before children, following the cascade), then updates, then collection
changes, then deletes. The @Version field adds where version = ? to each update and
increments it; if zero rows match, another transaction changed the row first and
Hibernate throws an optimistic locking exception — a cheap and effective way to prevent
lost updates, surfaced to clients as 409.
Common mistakes¶
- Updating only the inverse side (
author.getBooks().add(book)) and wondering whyauthor_idis null. CascadeType.ALLon@ManyToOne, deleting shared parents.- Leaving
@ManyToOneEAGER by default, causing extra queries on every load. - Lombok
@Data/@EqualsAndHashCodeon entities, which include lazy collections and can trigger loading or infinite recursion intoString. - Exposing the mutable
bookslist and letting callers add to it directly. ReturnCollections.unmodifiableList(books)and force changes throughaddBook.
Exercise¶
- Map
AuthorandBookas above against the migration from lesson 05. - Write a test that creates an author with two books using only
authors.save, then assertsbooks.count() == 2and that each book's author id is set. - Remove one book via
author.removeBook(...)inside a transaction and confirm the row is deleted (orphan removal). Then removeorphanRemovaland observe what happens instead (hint: thenot nullconstraint onauthor_id). - Add
Tagwith a many-to-many toBook. Then refactor it to an explicitBookTagentity with ataggedAttimestamp, and write the migration for it.