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
@ManyToOnedefaulting toEAGERis 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:
| Required | Why |
|---|---|
@Entity | Marks the class for the metamodel and adds it to the persistence unit. |
| No-arg constructor | JPA reflectively instantiates entities; visibility ≥ protected. |
Non-final class | Hibernate generates a runtime subclass for proxying. |
@Id field or property | Identity 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
finalclass — or afinalgetter on a mapped property — breaks Hibernate's runtime proxy. Lazy associations stop being lazy or fail outright depending on bytecode-enhancement mode. Never make entitiesfinal.
Exam tip: Hashing on the auto-generated
@Idis a classic bug. The ID isnullfor transient instances and changes afterpersist(), so the entity moves between hash buckets and disappears from anySetit was added to before flush.
Identifier Strategies
@GeneratedValue exposes four concrete strategies plus the special AUTO:
| Strategy | How It Generates | Batch INSERT | Round-trips per INSERT | Notes |
|---|---|---|---|---|
IDENTITY | DB auto-increment column | Disabled | 1 (must read back the key) | Default on MySQL; kills JDBC batching. |
SEQUENCE | DB sequence object, pre-fetched in blocks | Enabled | 0 (after block fetch) | Default on PostgreSQL/Oracle; senior-dev choice. |
TABLE | Dedicated table holding the next value | Enabled | 1 per block | Portable but slow — avoid unless DB has no sequences. |
UUID | Generated by Hibernate (UUIDv4 / time-based) | Enabled | 0 | Use when IDs must be assigned client-side or pre-flush. |
AUTO | Provider chooses | varies | varies | Resolution 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, callingentityManager.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.SEQUENCEwithallocationSize > 1only 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.
@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
@OneToManywithoutmappedByand without@JoinColumnproduces a third table (author_books) with two FKs. The exam tests whether you can spot this from the generated DDL.
Exam tip:
mappedByreferences the property name on the owning side, not the column name. If the field onBookisauthor, writemappedBy = "author"even though the column isauthor_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:
@JoinColumnon a@OneToManywithoutmappedBymakes the relationship unidirectional with the FK on the child table — a third option that confuses people. Most code wants eithermappedBy(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:
| Annotation | Default Fetch | Right Choice for Senior Code |
|---|---|---|
@ManyToOne | EAGER | LAZY (always override) |
@OneToOne | EAGER | LAZY (override; needs optional = false to truly stay LAZY) |
@OneToMany | LAZY | LAZY (keep) |
@ManyToMany | LAZY | LAZY (keep) |
- +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:
- 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-
finalclass and non-finalgetters — that's where the "nofinalentities" rule comes from. (CGLIB is a different bytecode library used by Spring AOP, not by Hibernate.) - 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
@ManyToOnedefaults, 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
@ManyToOneis EAGER. Default for@OneToManyis LAZY. Mixing these defaults is the single largest source of accidental N+1 queries.
Exam tip:
LazyInitializationExceptionis thrown when a LAZY association is touched outside an openEntityManager. 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. Withoutoptional = 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:
| CascadeType | What It Cascades | Typical Use |
|---|---|---|
PERSIST | em.persist(parent) | Save children when saving the aggregate root |
MERGE | em.merge(parent) | Re-attach the whole graph from a detached state |
REMOVE | em.remove(parent) | Delete children when deleting the parent |
REFRESH | em.refresh(parent) | Force-reload the entire graph |
DETACH | em.detach(parent) | Drop the graph from the persistence context |
ALL | All five above | Tightly-coupled aggregates only |
Five independent flags. Each one gates a single EntityManager op.
Declared in the relationship annotation, e.g. cascade = {PERSIST, MERGE}.
Boolean attribute on @OneToMany / @OneToOne. Triggered by graph mutation, not by an EntityManager op.
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.REMOVEfires when the parent is removed (em.remove(parent)). The cascade walks the association and callsem.remove(child)on each.orphanRemoval = truefires when a child is disassociated from its parent's collection — even while the parent stays alive. Removing a child fromparent.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 BookThese 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.PERSISTandMERGE, plusorphanRemovalif parts cannot exist alone. AddREMOVEonly if the database doesn't already enforceON DELETE CASCADE. - Part → aggregate root (
@ManyToOneside): no cascade. The child's lifecycle should never drive the parent's.
Exam tip:
orphanRemoval = trueonly applies to@OneToOneand@OneToMany. The relationship must logically own the child —@ManyToOneand@ManyToManyare not allowed targets.
Exam tip:
cascade = CascadeType.ALLdoes not implyorphanRemoval. They are independent flags and you usually want both on@OneToManyaggregate-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.
- id PK
- name
- animal_type
- breed (nullable)
- indoor (nullable)
- 1 · Rex · DOG · Lab · NULL
- 2 · Misty · CAT · NULL · true
- id PK
- name
- id PK · FK→animals
- breed
- id PK · FK→animals
- indoor
- id PK
- name
- breed
- id PK
- name
- indoor
| Strategy | Schema | Polymorphic Query Cost | Nullable Subclass Columns | Pick When |
|---|---|---|---|---|
SINGLE_TABLE (default) | One table for the whole hierarchy + discriminator column | 1 query | Yes — every subclass field lives on the same row | Many subclasses, frequent polymorphic reads, you accept nullable columns |
JOINED | Parent table + one table per subclass joined on PK | 1 query with N joins | No | You want a normalized schema and don't query polymorphically often |
TABLE_PER_CLASS | One table per concrete subclass, no parent table | UNION across subclass tables | No | Almost 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@Inheritanceis omitted and you have a subclass with@Entity, you get a single table with all subclass columns nullable.
Exam tip:
TABLE_PER_CLASScannot useGenerationType.IDENTITYbecause IDs must be unique across all concrete subclass tables. UseSEQUENCEorTABLEinstead — Hibernate enforces this at boot.
Exam tip:
@MappedSuperclassis not inheritance mapping. You cannot polymorphically query the superclass, point a@ManyToOneat 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
@Embeddableneeds a no-arg constructor (public or protected).Serializableis only required when the embeddable is used as a primary key (@EmbeddedIdor@IdClass) — for value objects likeAddress, the spec does not require it.
Exam tip: Composite-key classes (both
@EmbeddedId-target and@IdClass) must overrideequalsandhashCode. Without them, the persistence context cannot match identity and entities are duplicated onfind().
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.
FROM Author a
LEFT JOIN FETCH a.books b
- ►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
-
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); -
@EntityGraph— declarative, composes with derived queries:@EntityGraph(attributePaths = "books") List<Author> findByCountry(String country); -
@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<>(); -
@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 FETCHcannot be combined with pagination on collection associations without in-memory paging. The exam may showsetMaxResultsafter aJOIN FETCHand ask what happens — the answer is "Hibernate logs a warning and pages in memory after fetching the full result set."
Exam tip:
@EntityGraphis the JPA-standard equivalent ofJOIN FETCHand 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.
@OneToManywithoutmappedByand without@JoinColumn→ JPA invents a junction table you didn't declare.@ManyToOnedefaulting to EAGER → silent N+1 the moment you load any list of children.finalentity /finalgetter → broken proxying; LAZY associations may eager-load or fail at runtime.equals/hashCodebased on auto-generated@Id→ entity disappears from aSetafter persist.CascadeType.REMOVEconfused withorphanRemoval→ orphans hang around (or get deleted when they shouldn't).CascadeType.ALLon@ManyToOne→ removing the child takes the parent and all sibling children with it.TABLE_PER_CLASSwithIDENTITY→ ID collision across subclass tables; Hibernate refuses at boot.@OneToOne(optional = true, fetch = LAZY)→ still eager. Needsoptional = falseor bytecode enhancement.- Forgetting
mappedByon@OneToOne→ both sides own the relationship, schema has duplicate FKs. - Catching
LazyInitializationExceptionand reloading by hand → masks the real bug; the right fix isJOIN FETCHor@EntityGraph. @MappedSuperclassused where polymorphic queries are needed → JPQL fails; you cannot select the superclass.JOIN FETCH+setMaxResultson 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:
- Own the FK side. The owning side is the side with the foreign-key column; it is the only side Hibernate writes from.
mappedByon the inverse side is a navigation hint, not a database write. - Default to LAZY. Override
@ManyToOneand@OneToOne(optional = false)toFetchType.LAZYeverywhere. UseJOIN FETCHor@EntityGraphto opt into eager loading at the call site. - Never
CascadeType.ALLon@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
- Spring Data JPA Exam Guide — the sister post on repositories, derived queries, and
@Transactional. - Spring AOP and Pointcut Expressions — the proxy mechanism behind LAZY loading.
- Spring Bean Lifecycle Explained — for the entity-lifecycle vs bean-lifecycle distinction the exam likes to blur.
- Spring Professional Exam Questions 2025 — sample questions across the full syllabus.
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.