All articles
Spring DataExam TipsJPA

JPA Entity Mapping Exam Guide (2V0-72.22)

May 6, 202624 min read

What Entity Mapping Actually Means

JPA entity mapping is two things at once: a declarative bridge from your object graph to a relational schema, and a runtime contract Hibernate enforces — proxying, identity tracking, dirty checking, lifecycle callbacks. Get either side wrong and the bug usually surfaces far away from the annotation that caused it.

This post is the deep dive deliberately deferred from the Spring Data JPA exam guide. Where that post explained repositories, queries, and @Transactional, this one covers what happens to the entities themselves: how the schema is generated, when associations are loaded, what cascade really propagates, and which of the three inheritance strategies you should actually pick.

On the 2V0-72.22 exam, mapping questions sit inside the Data Management section. Expect 4–6 items that hinge on knowing the defaults: which fetch type applies to which relationship, what mappedBy does, how orphanRemoval differs from CascadeType.REMOVE, and which inheritance strategy ships if you don't pick one.

By the end of this guide you'll know:

  • The three rules that prevent ~80% of mapping bugs in production code.
  • Why @ManyToOne defaulting to EAGER is the single largest source of N+1 queries.
  • Where the spec's defaults and Hibernate's defaults diverge in practice.

All examples use Hibernate 6 / Jakarta Persistence (jakarta.persistence.*), matching Spring 6 / Boot 3 codebases. The 2V0-72.22 exam itself was originally calibrated to Spring 5.3 / Boot 2.5–2.7 (which use javax.persistence.*); the package name is the only difference — semantics are identical for everything in this guide.


The Entity Contract

A class becomes an entity by declaring three things: @Entity, an identifier (@Id), and a no-arg constructor.

@Entity
@Table(name = "authors")
public class Author {
 
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "author_seq")
    @SequenceGenerator(name = "author_seq", sequenceName = "author_seq", allocationSize = 50)
    private Long id;
 
    @Column(nullable = false, length = 120)
    private String name;
 
    @Transient
    private int unsavedDraftCount;
 
    protected Author() {}                // JPA needs this; can be protected
 
    public Author(String name) { this.name = name; }
    // getters / setters / equals / hashCode below
}

The non-negotiable parts:

RequiredWhy
@EntityMarks the class for the metamodel and adds it to the persistence unit.
No-arg constructorJPA reflectively instantiates entities; visibility ≥ protected.
Non-final classHibernate generates a runtime subclass for proxying.
@Id field or propertyIdentity is mandatory; without it the schema generation fails.

Optional but exam-relevant: @Table (override the table name), @Column (nullable, unique, length, columnDefinition), @Transient (skip the field), and @Access(FIELD) vs @Access(PROPERTY) (whether Hibernate reads via fields or getters — picked from where you put @Id).

equals and hashCode — the JPA-friendly contract

Textbook advice ("hash and equate on all fields") breaks here. Hibernate moves the same logical entity through three states — transient, managed, detached — and the auto-generated @Id is null until persist flushes. If hashCode depends on the ID, an entity stored in a HashSet before persist disappears from the set after persist (its hash bucket changes). If equals depends only on the ID, two transient instances are "equal" because both have null IDs.

The senior-developer answer is one of two patterns:

// Option A — business key (preferred when one exists)
@Override public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof Author a)) return false;
    return Objects.equals(isbn, a.isbn);          // immutable business key
}
@Override public int hashCode() { return Objects.hash(isbn); }
 
// Option B — when no business key exists, use a stable hashCode
@Override public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof Author a)) return false;
    return id != null && id.equals(a.id);
}
@Override public int hashCode() { return getClass().hashCode(); }

Exam tip: A final class — or a final getter on a mapped property — breaks Hibernate's runtime proxy. Lazy associations stop being lazy or fail outright depending on bytecode-enhancement mode. Never make entities final.

Exam tip: Hashing on the auto-generated @Id is a classic bug. The ID is null for transient instances and changes after persist(), so the entity moves between hash buckets and disappears from any Set it was added to before flush.


Identifier Strategies

@GeneratedValue exposes four concrete strategies plus the special AUTO:

StrategyHow It GeneratesBatch INSERTRound-trips per INSERTNotes
IDENTITYDB auto-increment columnDisabled1 (must read back the key)Default on MySQL; kills JDBC batching.
SEQUENCEDB sequence object, pre-fetched in blocksEnabled0 (after block fetch)Default on PostgreSQL/Oracle; senior-dev choice.
TABLEDedicated table holding the next valueEnabled1 per blockPortable but slow — avoid unless DB has no sequences.
UUIDGenerated by Hibernate (UUIDv4 / time-based)Enabled0Use when IDs must be assigned client-side or pre-flush.
AUTOProvider choosesvariesvariesResolution rules changed between Hibernate 5 and 6 — see below.

Why this matters for performance: IDENTITY forces Hibernate to flush each insert immediately (it has to ask the database for the generated key before it can register the entity in the persistence context), so JDBC batching is impossible. SEQUENCE with allocationSize = 50 reserves 50 IDs in a single round-trip and inserts the next 50 entities in one batch.

@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "author_seq")
@SequenceGenerator(name = "author_seq", sequenceName = "author_seq", allocationSize = 50)
private Long id;

AUTO resolution — the Hibernate 5 → 6 change

Hibernate 5: AUTO fell back to SEQUENCE only if the dialect explicitly supported it; otherwise to TABLE. On MySQL that meant TABLE.

Hibernate 6: AUTO resolves to SEQUENCE whenever the database supports sequences (Postgres, Oracle, MariaDB 10.3+, SQL Server 2012+) and to IDENTITY otherwise. MySQL has no native SQL sequences in any version, so on MySQL AUTO resolves to IDENTITY — and the resulting DDL, batch-insert performance, and generator names all differ from what Hibernate 5 produced (TABLE).

Exam tip: With GenerationType.IDENTITY, calling entityManager.persist(entity) triggers an immediate INSERT instead of a deferred one. The ID has to come from the database before the persistence context can register the entity — so Hibernate cannot postpone the SQL until flush.

Exam tip: GenerationType.SEQUENCE with allocationSize > 1 only saves round-trips when Hibernate is allowed to manage the increments in memory. If the same sequence is shared with another application or a manual SQL script, gaps appear in the ID space — that is normal, not a bug.


Relationships — Owning Side, Inverse Side, and mappedBy

This is the most-tested topic in the entire mapping area. The single rule that explains every relationship behavior:

The owning side is the side that holds the foreign-key column. Only changes to the owning side are written to the database.

The inverse side exists for navigation only — modifying its collection has zero effect on the schema unless the owning side is also updated.

Owning side vs inverse side in a bidirectional @OneToMany / @ManyToOneAuthor has @OneToMany(mappedBy = "author") Set books — inverse side, no foreign-key column. Book has @ManyToOne and @JoinColumn(name = "author_id") — owning side, holds the FK column. Only changes to the owning side (Book.setAuthor) are written to the database on flush.Authorid, nameno FK columnINVERSE SIDE@OneToMany(mappedBy = "author")List<Book> booksBookid, title, author_id (FK)holds the foreign keyOWNING SIDE@ManyToOne@JoinColumn(name = "author_id")FK author_idRULEOnly changes to the owning side (Book.setAuthor) produce SQL on flush.Mutating the inverse collection alone writes nothing — the database never sees it.

@ManyToOne — always owning

The "many" side has the FK column, so it owns the relationship. Default fetch is EAGER — almost always the wrong choice (see the next section).

@Entity
public class Book {
    @Id @GeneratedValue private Long id;
 
    @ManyToOne(fetch = FetchType.LAZY)         // override the EAGER default
    @JoinColumn(name = "author_id")            // FK column on books table
    private Author author;
}

@OneToMany — almost always inverse

Pair with mappedBy referencing the owning-side property name (not the column name). Without mappedBy and without @JoinColumn, JPA generates a junction table you didn't ask for.

@Entity
public class Author {
    @Id @GeneratedValue private Long id;
 
    @OneToMany(mappedBy = "author",            // name of the field on Book
               cascade = CascadeType.PERSIST,
               orphanRemoval = true)
    private List<Book> books = new ArrayList<>();
}

Exam tip: A @OneToMany without mappedBy and without @JoinColumn produces a third table (author_books) with two FKs. The exam tests whether you can spot this from the generated DDL.

Exam tip: mappedBy references the property name on the owning side, not the column name. If the field on Book is author, write mappedBy = "author" even though the column is author_id.

Bidirectional consistency

If both sides are mapped, application code has to keep them in sync. Helper methods on the parent are the standard pattern:

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

Why bother — Hibernate only reads the owning side when persisting, but the in-memory object graph is queried by your code, your tests, and any Jackson serializer in the call chain. If book.setAuthor(a) runs without a.getBooks().add(book), the database is correct but a.getBooks() returns stale data until the next reload (or never, if the entity stays in the same persistence context).

@OneToOne

Owning side has the FK; the other side uses mappedBy. For shared-PK relationships use @MapsId:

@Entity
public class UserAccount {
    @Id @GeneratedValue private Long id;
 
    @OneToOne(mappedBy = "account")            // inverse side
    private UserProfile profile;
}
 
@Entity
public class UserProfile {
    @Id private Long id;                       // same value as account.id
 
    @OneToOne(fetch = FetchType.LAZY, optional = false)
    @MapsId                                    // PK column = FK column
    @JoinColumn(name = "id")
    private UserAccount account;
}

The optional = false matters for fetch — see the next section.

@ManyToMany

Both sides can declare it; one chooses owning via mappedBy. JPA creates a join table automatically.

@Entity
public class Course {
    @ManyToMany
    @JoinTable(name = "course_student",
               joinColumns = @JoinColumn(name = "course_id"),
               inverseJoinColumns = @JoinColumn(name = "student_id"))
    private Set<Student> students = new HashSet<>();
}
 
@Entity
public class Student {
    @ManyToMany(mappedBy = "students")
    private Set<Course> courses = new HashSet<>();
}

Almost every senior @ManyToMany decision should be: don't — model the join row as its own entity instead. The moment you need an extra column (enrolledAt, grade), you have to migrate the @ManyToMany to two @ManyToOnes anyway, and the migration is painful with data already in the join table. Start with the explicit entity:

@Entity
public class Enrollment {
    @EmbeddedId private EnrollmentId id;       // (course_id, student_id)
    @ManyToOne @MapsId("courseId")  private Course course;
    @ManyToOne @MapsId("studentId") private Student student;
    private Instant enrolledAt;
}

Exam tip: Removing an item from the inverse-side collection alone — without changing the owning side — does not delete or unlink the row. The flush sees no change on the owning side and emits no SQL.

Exam tip: @JoinColumn on a @OneToMany without mappedBy makes the relationship unidirectional with the FK on the child table — a third option that confuses people. Most code wants either mappedBy (bidirectional) or accepts the default join table.


Fetch Strategies — LAZY, EAGER, and the Proxy Mechanism

The defaults are not consistent across the four cardinalities, and the inconsistency is the single largest source of accidental N+1 queries in production code:

AnnotationDefault FetchRight Choice for Senior Code
@ManyToOneEAGERLAZY (always override)
@OneToOneEAGERLAZY (override; needs optional = false to truly stay LAZY)
@OneToManyLAZYLAZY (keep)
@ManyToManyLAZYLAZY (keep)
JPA default fetch strategies and how chained EAGER associations multiply queriesUser has a @OneToOne to Profile (EAGER by default — solid red edge), a @OneToMany to Order (LAZY by default — dashed edge), and Order has a @ManyToOne back to Customer (EAGER by default — solid red edge, marked as a trap). Loading a single User triggers extra SELECTs for every solid-red edge that is reachable; the chained Customer SELECTs only fire once the orders collection is touched.@OneToOne · 1 → 1EAGER@OneToMany · 1 → NLAZY@ManyToOne · N → 1EAGERUserem.find(User, 1L)entry pointProfile+1 SELECT at find()unconditionalOrder+1 SELECT on getOrders()lazy — proxy until touchedCustomer+N SELECTs (per Order)⚠ trap — chained EAGEREAGERLAZYchained EAGER trap
QUERY BUDGETfor one User with N Orders, defaults unchanged
  • +2em.find(User, 1L) — User row + Profile (EAGER @OneToOne)
  • +1user.getOrders() — collection initialized (LAZY @OneToMany)
  • +Niterate orders — one Customer SELECT per Order (EAGER @ManyToOne)
  • = 3+Nround-trips for one User — N grows with the page size, hence the N+1 trap

How LAZY actually works

Hibernate generates a proxy subclass of your entity at boot. Accessing any non-@Id getter on a LAZY association triggers a SELECT to populate the proxy. Detached entity + LAZY association = LazyInitializationException, because there is no open persistence context to issue the SELECT.

There are two implementation modes:

  1. Runtime proxies (default) — Hibernate generates a runtime subclass of each entity using ByteBuddy (replaced Javassist in Hibernate 5.3+). The subclass overrides every getter to trigger initialization on first access. Requires a non-final class and non-final getters — that's where the "no final entities" rule comes from. (CGLIB is a different bytecode library used by Spring AOP, not by Hibernate.)
  2. Bytecode enhancement (hibernate-enhance-maven-plugin) — Hibernate rewrites the entity bytecode at compile time. Allows lazy fetching of individual fields (not just associations) and removes the runtime proxy overhead. Most applications never need it.

Why @ManyToOne defaults to EAGER

The JPA spec rationale: a @ManyToOne association is "to-one", so loading the parent is "free" — one extra row joined into the result. In practice it isn't free, because:

  • Each fetched parent has its own @ManyToOne defaults, fanning out further.
  • Pagination amplifies the cost: a page of 50 orders eagerly loading their customer triggers 50 extra SELECTs (or one big join, depending on dialect heuristics).
  • Application code rarely needs the parent on every read.

The senior-developer default is LAZY for everything, fetch joins where needed:

@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "customer_id")
private Customer customer;

The @OneToOne LAZY trap

@OneToOne(optional = true) defeats LAZY even when you set fetch = LAZY. Hibernate has to know whether the association exists (to return null from the getter or a proxy), so it issues a SELECT regardless. The fix is optional = false:

@OneToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "profile_id")
private Profile profile;

If the association is genuinely optional, switch to @MapsId shared-PK or to @OneToOne on the inverse side with bytecode enhancement.

Exam tip: Default fetch type for @ManyToOne is EAGER. Default for @OneToMany is LAZY. Mixing these defaults is the single largest source of accidental N+1 queries.

Exam tip: LazyInitializationException is thrown when a LAZY association is touched outside an open EntityManager. Open-Session-In-View masks the symptom by keeping the session open through the controller layer — it is enabled by default in Spring Boot but disabling it (spring.jpa.open-in-view=false) is the senior-dev default for any non-trivial app.

Exam tip: @OneToOne(optional = true, fetch = LAZY) does not stay lazy. Without optional = false (or bytecode enhancement) Hibernate must hit the database to know whether the association exists.


Cascading and orphanRemoval

Cascading propagates a JPA operation from a parent entity to its associated entities. Five concrete types plus ALL:

CascadeTypeWhat It CascadesTypical Use
PERSISTem.persist(parent)Save children when saving the aggregate root
MERGEem.merge(parent)Re-attach the whole graph from a detached state
REMOVEem.remove(parent)Delete children when deleting the parent
REFRESHem.refresh(parent)Force-reload the entire graph
DETACHem.detach(parent)Drop the graph from the persistence context
ALLAll five aboveTightly-coupled aggregates only
CASCADEparent op propagates to children

Five independent flags. Each one gates a single EntityManager op.

PARENTem.<op>(parent) called
flagfires on
PERSIST·em.persist(parent)
MERGE·em.merge(parent)
REMOVE·em.remove(parent)
REFRESH·em.refresh(parent)
DETACH·em.detach(parent)
ALL=shorthand — enables all five flags above
CHILD
same op runs on each child only if that flag is enabled

Declared in the relationship annotation, e.g. cascade = {PERSIST, MERGE}.

orphanRemovalindependent flag · fires on collection mutation

Boolean attribute on @OneToMany / @OneToOne. Triggered by graph mutation, not by an EntityManager op.

PARENTstill alive · still managed
any of these mutations…
parent.getChildren().remove(child)
parent.getChildren().clear()
parent.setChild(null) // @OneToOne
if orphanRemoval = true
else: child stays in the DB, FK becomes null
DELETE child

Not implied by CascadeType.ALL — the two flags are independent and you usually want both.

orphanRemoval is not CascadeType.REMOVE

The two are commonly confused but trigger differently:

  • CascadeType.REMOVE fires when the parent is removed (em.remove(parent)). The cascade walks the association and calls em.remove(child) on each.
  • orphanRemoval = true fires when a child is disassociated from its parent's collection — even while the parent stays alive. Removing a child from parent.getChildren() (or replacing the collection via setter) marks that child for deletion.
// orphanRemoval — child disappears from collection, parent stays
parent.getBooks().remove(book);             // emits DELETE on Book
em.flush();
 
// CascadeType.REMOVE — parent is removed, children follow
em.remove(parent);                          // emits DELETE on Author and on each Book

These flags are independent. CascadeType.ALL does not include orphanRemoval — you have to set both:

@OneToMany(mappedBy = "author",
           cascade = CascadeType.ALL,
           orphanRemoval = true)
private List<Book> books = new ArrayList<>();

Why CascadeType.ALL on @ManyToOne is a trap

Putting cascade = CascadeType.ALL on the many side means deleting any one Book also deletes its Author — and therefore every other Book belonging to that author. The mistake passes code review because the annotation lives on the side you happened to be editing.

Cascade rules of thumb:

  • Aggregate root → parts: CascadeType.PERSIST and MERGE, plus orphanRemoval if parts cannot exist alone. Add REMOVE only if the database doesn't already enforce ON DELETE CASCADE.
  • Part → aggregate root (@ManyToOne side): no cascade. The child's lifecycle should never drive the parent's.

Exam tip: orphanRemoval = true only applies to @OneToOne and @OneToMany. The relationship must logically own the child — @ManyToOne and @ManyToMany are not allowed targets.

Exam tip: cascade = CascadeType.ALL does not imply orphanRemoval. They are independent flags and you usually want both on @OneToMany aggregate-root associations.


Inheritance Mapping

JPA exposes three strategies for mapping an inheritance hierarchy. They differ in schema layout, query cost, and which JPA features they support.

SINGLE_TABLEdefault
schema
animals
  • id PK
  • name
  • animal_type
  • breed (nullable)
  • indoor (nullable)
1 table · discriminator column
sample rows
  • 1 · Rex · DOG · Lab · NULL
  • 2 · Misty · CAT · NULL · true
query1 SELECT · no joins
schemasubclass columns nullable
verdictfastest reads · denormalized
JOINEDnormalized
schema
animals
  • id PK
  • name
dogs
  • id PK · FK→animals
  • breed
cats
  • id PK · FK→animals
  • indoor
query1 SELECT with N joins
schemano nullable columns
verdictbest schema clarity · slower reads
TABLE_PER_CLASSavoid
schema
animals
no parent table · subclasses stand alone
dogs
  • id PK
  • name
  • breed
cats
  • id PK
  • name
  • indoor
queryUNION across subclass tables
schemacannot use GenerationType.IDENTITY
verdictpolymorphic queries are slow
StrategySchemaPolymorphic Query CostNullable Subclass ColumnsPick When
SINGLE_TABLE (default)One table for the whole hierarchy + discriminator column1 queryYes — every subclass field lives on the same rowMany subclasses, frequent polymorphic reads, you accept nullable columns
JOINEDParent table + one table per subclass joined on PK1 query with N joinsNoYou want a normalized schema and don't query polymorphically often
TABLE_PER_CLASSOne table per concrete subclass, no parent tableUNION across subclass tablesNoAlmost never. Cannot use GenerationType.IDENTITY; polymorphic queries are slow
// Default — SINGLE_TABLE
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "animal_type", discriminatorType = DiscriminatorType.STRING)
public abstract class Animal {
    @Id @GeneratedValue Long id;
    String name;
}
 
@Entity @DiscriminatorValue("DOG")
public class Dog extends Animal { String breed; }
 
@Entity @DiscriminatorValue("CAT")
public class Cat extends Animal { boolean indoor; }

@DiscriminatorColumn and @DiscriminatorValue are meaningful primarily for SINGLE_TABLE, where the discriminator is how Hibernate tells one subclass from another in a shared row. Both annotations are optional even for SINGLE_TABLE — if omitted, the spec defaults to a VARCHAR(31) column named DTYPE with the entity name as the value. For JOINED they are allowed but unnecessary: the row's concrete type is determined by which subclass table the join hits.

@MappedSuperclass is not inheritance mapping

This is a separate, frequently-confused annotation. @MappedSuperclass lets multiple entities share field declarations without producing a polymorphic type hierarchy. The superclass is not an entity — it has no table, cannot be referenced in JPQL, cannot be the target of @ManyToOne, and you cannot polymorphically query it.

@MappedSuperclass
public abstract class Auditable {
    @CreationTimestamp Instant createdAt;
    @UpdateTimestamp   Instant updatedAt;
}
 
@Entity public class Order   extends Auditable { /* ... */ }
@Entity public class Invoice extends Auditable { /* ... */ }
// "SELECT a FROM Auditable a" — fails. Auditable is not an entity.

Exam tip: Default inheritance strategy is SINGLE_TABLE. If @Inheritance is omitted and you have a subclass with @Entity, you get a single table with all subclass columns nullable.

Exam tip: TABLE_PER_CLASS cannot use GenerationType.IDENTITY because IDs must be unique across all concrete subclass tables. Use SEQUENCE or TABLE instead — Hibernate enforces this at boot.

Exam tip: @MappedSuperclass is not inheritance mapping. You cannot polymorphically query the superclass, point a @ManyToOne at it, or use it as a JPQL FROM target.


Embeddables and Composite Keys

Embeddables flatten a value-object class into the owning entity's table. Classic use cases are Address, Money, and DateRange.

@Embeddable
public class Address {
    @Column(name = "street")  private String street;
    @Column(name = "city")    private String city;
    @Column(name = "country") private String country;
 
    protected Address() {}
    public Address(String street, String city, String country) { /* ... */ }
}
 
@Entity
public class Customer {
    @Id @GeneratedValue private Long id;
 
    @Embedded private Address billingAddress;
 
    @Embedded
    @AttributeOverrides({
        @AttributeOverride(name = "street",  column = @Column(name = "shipping_street")),
        @AttributeOverride(name = "city",    column = @Column(name = "shipping_city")),
        @AttributeOverride(name = "country", column = @Column(name = "shipping_country"))
    })
    private Address shippingAddress;
}

The single customers table now has six columns (three for billing, three for shipping). @AttributeOverride is mandatory when the same @Embeddable appears twice — otherwise both copies map to the same columns and Hibernate fails at boot.

Composite primary keys

Two ways to model a composite PK:

// @EmbeddedId — preferred, idiomatic
@Embeddable
public class EnrollmentId implements Serializable {
    private Long courseId;
    private Long studentId;
    // equals/hashCode — required
}
 
@Entity
public class Enrollment {
    @EmbeddedId private EnrollmentId id;
}
 
// @IdClass — older, more verbose; the ID class lives outside
public class EnrollmentId implements Serializable {
    private Long courseId;
    private Long studentId;
}
 
@Entity
@IdClass(EnrollmentId.class)
public class Enrollment {
    @Id private Long courseId;
    @Id private Long studentId;
}

Prefer @EmbeddedId — the ID is a real field and the JPQL syntax (SELECT e.id.courseId FROM Enrollment e) is consistent. @IdClass is mostly seen in legacy code.

Exam tip: Every @Embeddable needs a no-arg constructor (public or protected). Serializable is only required when the embeddable is used as a primary key (@EmbeddedId or @IdClass) — for value objects like Address, the spec does not require it.

Exam tip: Composite-key classes (both @EmbeddedId-target and @IdClass) must override equals and hashCode. Without them, the persistence context cannot match identity and entities are duplicated on find().


The N+1 Problem and How to Kill It

The N+1 query problem is the canonical mapping failure. The pattern: load N parents, then iterate them and touch a LAZY association on each. Each touch issues one extra SELECT — 1 + N round-trips.

1 + N QUERIES9 round-trips
SELECT * FROM authors
returns N parents
then for each parent…
SELECT * FROM books WHERE author_id = ?
SELECT * FROM books WHERE author_id = ?
SELECT * FROM books WHERE author_id = ?
SELECT * FROM books WHERE author_id = ?
SELECT * FROM books WHERE author_id = ?
SELECT * FROM books WHERE author_id = ?
SELECT * FROM books WHERE author_id = ?
SELECT * FROM books WHERE author_id = ?
1 QUERY · JOIN FETCH1 round-trip
SELECT a, b
FROM Author a
LEFT JOIN FETCH a.books b
parents and children loaded together
  • One SQL statement, regardless of N.
  • Equivalent declarative form: @EntityGraph(attributePaths = "books").
  • Cannot combine with setMaxResults on a collection without in-memory paging.
// 1 query: SELECT * FROM authors LIMIT 100
List<Author> authors = authorRepo.findAll(PageRequest.of(0, 100)).getContent();
 
// + N queries — one SELECT * FROM books WHERE author_id = ? per author
for (Author a : authors) {
    log.info("{} has {} books", a.getName(), a.getBooks().size());
}

Mitigations, ranked

  1. JOIN FETCH — most explicit, no surprise at the call site:

    @Query("SELECT a FROM Author a LEFT JOIN FETCH a.books WHERE a.country = :country")
    List<Author> findWithBooksByCountry(@Param("country") String country);
  2. @EntityGraph — declarative, composes with derived queries:

    @EntityGraph(attributePaths = "books")
    List<Author> findByCountry(String country);
  3. @BatchSize — Hibernate-specific; loads collections in batches of N when first touched:

    @OneToMany(mappedBy = "author")
    @org.hibernate.annotations.BatchSize(size = 50)
    private List<Book> books = new ArrayList<>();
  4. @Fetch(FetchMode.SUBSELECT) — issues a single secondary query that loads collections for all parents already in context.

The JOIN FETCH + pagination trap

Fetching a @OneToMany with JOIN FETCH multiplies the result set: 10 authors with 5 books each return 50 rows. Hibernate deduplicates the parents in memory when you add DISTINCT — but setMaxResults and setFirstResult cannot be applied at the SQL level when the result is a multiplied collection. Hibernate logs a warning and pages in memory, fetching the full result set into the JVM and slicing client-side.

The fix is two queries: page the parent IDs first, then load with JOIN FETCH constrained by IDs.

// Step 1: page IDs only (single, narrow SELECT)
Page<Long> ids = authorRepo.findIds(PageRequest.of(0, 50));
 
// Step 2: fetch the page with associations
List<Author> page = authorRepo.findWithBooksByIdIn(ids.getContent());

Exam tip: JOIN FETCH cannot be combined with pagination on collection associations without in-memory paging. The exam may show setMaxResults after a JOIN FETCH and ask what happens — the answer is "Hibernate logs a warning and pages in memory after fetching the full result set."

Exam tip: @EntityGraph is the JPA-standard equivalent of JOIN FETCH and works on top of Spring Data derived queries. It is the right choice when you want to specify the fetch plan declaratively and reuse the same repository method.


Common Exam Traps

A condensed checklist. Skim this before the exam — each item is a single-bullet diagnosis, not a tutorial.

  • @OneToMany without mappedBy and without @JoinColumn → JPA invents a junction table you didn't declare.
  • @ManyToOne defaulting to EAGER → silent N+1 the moment you load any list of children.
  • final entity / final getter → broken proxying; LAZY associations may eager-load or fail at runtime.
  • equals / hashCode based on auto-generated @Id → entity disappears from a Set after persist.
  • CascadeType.REMOVE confused with orphanRemoval → orphans hang around (or get deleted when they shouldn't).
  • CascadeType.ALL on @ManyToOne → removing the child takes the parent and all sibling children with it.
  • TABLE_PER_CLASS with IDENTITY → ID collision across subclass tables; Hibernate refuses at boot.
  • @OneToOne(optional = true, fetch = LAZY) → still eager. Needs optional = false or bytecode enhancement.
  • Forgetting mappedBy on @OneToOne → both sides own the relationship, schema has duplicate FKs.
  • Catching LazyInitializationException and reloading by hand → masks the real bug; the right fix is JOIN FETCH or @EntityGraph.
  • @MappedSuperclass used where polymorphic queries are needed → JPQL fails; you cannot select the superclass.
  • JOIN FETCH + setMaxResults on a collection → Hibernate fetches everything and pages in memory.

Frequently Asked Questions

What's the default fetch type for each relationship?

@ManyToOne and @OneToOne default to EAGER. @OneToMany and @ManyToMany default to LAZY. The senior-developer default is to override @ManyToOne and @OneToOne to LAZY explicitly and use JOIN FETCH or @EntityGraph where parents are actually needed.

Is orphanRemoval = true the same as CascadeType.REMOVE?

No. CascadeType.REMOVE fires when the parent itself is removed (em.remove(parent)). orphanRemoval = true fires when a child is disassociated from its parent's collection (e.g., parent.getChildren().remove(child)) — the parent stays alive. The two flags are independent and you usually want both on @OneToMany aggregate-root associations.

Which inheritance strategy is the default?

SINGLE_TABLE. If you mark a class @Entity and another class @Entity extends Parent without an @Inheritance annotation, JPA produces one table containing all subclass columns, with non-applicable columns nullable, and a discriminator column.

Does Hibernate require a no-arg public constructor?

JPA requires a no-arg constructor with visibility ≥ protected. Hibernate is more permissive at runtime (it can call package-private and even private constructors with reflection), but the spec — and the exam — say protected is the floor. The class must also be non-final.

How is @MappedSuperclass different from @Inheritance(SINGLE_TABLE)?

@MappedSuperclass shares fields without producing a polymorphic type. The superclass has no table, cannot be queried in JPQL, and cannot be referenced by @ManyToOne. SINGLE_TABLE produces a real entity hierarchy where the parent is itself queryable, persisted, and can be the target of associations. Use @MappedSuperclass for cross-cutting fields like audit timestamps; use SINGLE_TABLE when the hierarchy is part of the domain model.

When should I prefer @EmbeddedId over @IdClass?

Always, unless you maintain legacy code that already uses @IdClass. @EmbeddedId makes the composite key a real field on the entity, JPQL syntax is consistent (e.id.courseId), and the surrounding code reads more clearly. @IdClass exists for compatibility with the EJB 2 era.


Three Rules That Prevent Most Mapping Bugs

If you take three things from this guide:

  1. Own the FK side. The owning side is the side with the foreign-key column; it is the only side Hibernate writes from. mappedBy on the inverse side is a navigation hint, not a database write.
  2. Default to LAZY. Override @ManyToOne and @OneToOne(optional = false) to FetchType.LAZY everywhere. Use JOIN FETCH or @EntityGraph to opt into eager loading at the call site.
  3. Never CascadeType.ALL on @ManyToOne. Cascading from a child to its parent is almost always a bug; the child's lifecycle should never drive the parent's.

The remaining 20% of mapping bugs are about cascade vs orphanRemoval, inheritance strategy choice, and the JOIN FETCH + pagination trap — all covered above.


Next Steps

When you are ready to drill the mapping section with realistic exam-style items — including the EAGER trap, the cascade vs orphanRemoval confusion, and the TABLE_PER_CLASS + IDENTITY boot failure — our question bank covers each gotcha with a worked explanation.

Practice This Topic

Reinforce what you've learned with free practice questions and detailed explanations.