JPA Relationships

2 minute read

Most applications have related entities — books belong to authors, orders contain items. JPA maps these relationships to foreign keys.


@ManyToOne / @OneToMany — The Most Common

@Entity
@Table(name = "authors")
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<>();

    // Helper methods
    public void addBook(Book book) {
        books.add(book);
        book.setAuthor(this);
    }

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

@Entity
@Table(name = "books")
public class Book {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "author_id", nullable = false)
    private Author author;
}
Side Annotation Has FK?
Many (Book) @ManyToOne ✅ author_id
One (Author) @OneToMany(mappedBy) ❌ inverse side

mappedBy = "author" means: “Look at the author field in Book for the FK.”


@ManyToMany

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

    @ManyToMany
    @JoinTable(
        name = "book_categories",
        joinColumns = @JoinColumn(name = "book_id"),
        inverseJoinColumns = @JoinColumn(name = "category_id")
    )
    private Set<Category> categories = new HashSet<>();
}

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

    private String name;

    @ManyToMany(mappedBy = "categories")
    private Set<Book> books = new HashSet<>();
}

Creates a join table: book_categories(book_id, category_id).


@OneToOne

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

    @OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    @JoinColumn(name = "detail_id")
    private BookDetail detail;
}

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

    @Column(length = 5000)
    private String summary;
    private int pageCount;
    private String language;
}

LAZY vs EAGER Loading

Fetch Type Behavior Default For
LAZY Loaded on access (separate query) @OneToMany, @ManyToMany
EAGER Loaded immediately (JOIN) @ManyToOne, @OneToOne
@ManyToOne(fetch = FetchType.LAZY)    // ✅ Use LAZY
private Author author;

@OneToMany(mappedBy = "author", fetch = FetchType.LAZY)   // default
private List<Book> books;

Rule: Always use LAZY. Fetch eagerly only when you know you need the data.


The N+1 Problem

// Loads all authors (1 query)
List<Author> authors = authorRepository.findAll();

// Accessing books triggers 1 query PER author = N+1 queries!
for (Author author : authors) {
    System.out.println(author.getName() + ": " + author.getBooks().size());
}
-- 1 query for authors
SELECT * FROM authors;
-- N queries for books (one per author!)
SELECT * FROM books WHERE author_id = 1;
SELECT * FROM books WHERE author_id = 2;
SELECT * FROM books WHERE author_id = 3;
... (100 authors = 100 extra queries)

Solution: JOIN FETCH

@Query("SELECT a FROM Author a JOIN FETCH a.books")
List<Author> findAllWithBooks();
-- Single query!
SELECT a.*, b.* FROM authors a JOIN books b ON a.id = b.author_id;

Solution: @EntityGraph

@EntityGraph(attributePaths = {"books"})
List<Author> findAll();

Cascade Types

@OneToMany(mappedBy = "author", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Book> books;
Cascade Effect
PERSIST Saving author saves books too
MERGE Updating author updates books
REMOVE Deleting author deletes books
ALL All of the above

orphanRemoval = true — if a book is removed from the list, it’s deleted from DB.


Key Takeaways

  1. @ManyToOne on the FK side, @OneToMany(mappedBy) on the inverse side.
  2. Always use LAZY loading — EAGER causes surprise queries.
  3. The N+1 problem is the #1 JPA performance issue — use JOIN FETCH or @EntityGraph.
  4. Use CascadeType.ALL + orphanRemoval for parent-child relationships.
  5. Use helper methods (addBook, removeBook) to keep both sides in sync.

What’s Next?

Next: Database Migrations — Flyway and Liquibase for production-grade schema management.

Updated: