Skip to content

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 why author_id is null.
  • CascadeType.ALL on @ManyToOne, deleting shared parents.
  • Leaving @ManyToOne EAGER by default, causing extra queries on every load.
  • Lombok @Data/@EqualsAndHashCode on entities, which include lazy collections and can trigger loading or infinite recursion in toString.
  • Exposing the mutable books list and letting callers add to it directly. Return Collections.unmodifiableList(books) and force changes through addBook.

Exercise

  1. Map Author and Book as above against the migration from lesson 05.
  2. Write a test that creates an author with two books using only authors.save, then asserts books.count() == 2 and that each book's author id is set.
  3. Remove one book via author.removeBook(...) inside a transaction and confirm the row is deleted (orphan removal). Then remove orphanRemoval and observe what happens instead (hint: the not null constraint on author_id).
  4. Add Tag with a many-to-many to Book. Then refactor it to an explicit BookTag entity with a taggedAt timestamp, and write the migration for it.