JPA models the relational cardinalities with four annotations:
@ManyToOne Customer customer; // many orders → one customer (the FK side)
@OneToMany List<OrderLine> lines; // one order → many lines
@OneToOne Cart cart; // one user → one cart
@ManyToMany Set<Tag> tags; // many posts ↔ many tags (join table)
@ManyToOne— the most common; the entity holds a foreign key to one parent.@OneToMany— the inverse collection side of a many-to-one.@OneToOne— a single related row, FK or shared primary key.@ManyToMany— both sides are collections, backed by a join table.
The FK always lives on the @ManyToOne/owning side, regardless of which direction you
navigate in code.
Rule of thumb: Reach for @ManyToOne first — most relationships are best modeled from the
child holding the foreign key.
The owning side owns the foreign key and is what Hibernate reads when writing to the DB. The
inverse side is a mirror marked with mappedBy; changes to it alone are ignored at
flush.
@Entity class Order {
@OneToMany(mappedBy = "order") // INVERSE side — "order" is the field on OrderLine
List<OrderLine> lines = new ArrayList<>();
}
@Entity class OrderLine {
@ManyToOne // OWNING side — holds the order_id FK
Order order;
}
If you add a line only to order.lines but never set line.setOrder(order), the FK column stays
null — because Hibernate persists from the owning side. That's why bidirectional
associations need a helper method that sets both sides.
Rule of thumb: The side without mappedBy owns the FK and drives persistence; always
synchronize both sides of a bidirectional link.
Because the in-memory object graph and the database must agree, and only the owning side writes the FK. A helper keeps both collections/references consistent in one place.
@Entity class Order {
@OneToMany(mappedBy = "order", cascade = ALL, orphanRemoval = true)
List<OrderLine> lines = new ArrayList<>();
void addLine(OrderLine line) { // synchronize BOTH sides
lines.add(line);
line.setOrder(this); // sets the owning-side FK
}
}
Without addLine, code that only does order.getLines().add(line) leaves line.order null, the
FK unset, and the in-memory graph inconsistent with what gets persisted. The helper makes correct
usage the easy path.
Rule of thumb: Always provide addX/removeX helpers that update both sides of a
bidirectional association.
Fetch type controls when an association is loaded. The defaults differ by cardinality, and the to-one defaults are a frequent performance trap.
@ManyToOne // default EAGER → loaded immediately with the parent
Customer customer;
@OneToMany // default LAZY → loaded only when the collection is touched
List<OrderLine> lines;
@ManyToOne(fetch = FetchType.LAZY) // override the eager default
Customer customer2;
@ManyToOne and @OneToOne default to EAGER, so loading one entity quietly drags in its
parents — multiply that across a list and you get a query storm. To-many associations default to
LAZY. Best practice: make everything LAZY and fetch what you need explicitly per query.
Rule of thumb: Set all associations to FetchType.LAZY and pull the data you need with join
fetch or entity graphs — never rely on EAGER.
It's when loading N parent rows triggers N additional queries — one per parent — to fetch a lazy association, turning one logical read into N+1 SQL statements.
List<Order> orders = repo.findAll(); // 1 query: SELECT * FROM orders
for (Order o : orders) {
o.getCustomer().getName(); // +1 query PER order to load the customer
}
// 100 orders → 101 queries
It appears whether the association is lazy (loaded on access in the loop) or eager (Hibernate issues a separate select per parent). It's the single most common JPA performance bug, often invisible until you watch the SQL log.
Rule of thumb: Spot N+1 by enabling SQL logging; fix it with a JOIN FETCH, an entity graph,
or batch fetching — not by looping.
JOIN FETCH tells Hibernate to load the association in the same query as the root entity, so
the whole graph arrives in one SQL statement instead of N+1.
@Query("SELECT o FROM Order o JOIN FETCH o.customer WHERE o.status = :s")
List<Order> findByStatusWithCustomer(@Param("s") Status s);
// single SQL: orders JOINed to customers — no per-row lookups
It works great for to-one and a single collection. Fetching two collections at once
causes a Cartesian product, and combining JOIN FETCH with pagination forces Hibernate to
paginate in memory (a warning in the log). For those cases prefer entity graphs or split
queries.
Rule of thumb: Use JOIN FETCH to collapse N+1 into one query; never fetch two collections in
a single join, and avoid mixing it with pagination.
An entity graph declaratively specifies which associations to fetch eagerly for a given query, without writing JPQL. Spring Data lets you attach one to a repository method.
@EntityGraph(attributePaths = {"customer", "lines"})
List<Order> findByStatus(Status status); // fetches customer + lines eagerly
Versus JOIN FETCH, an entity graph is reusable and keeps the fetch plan separate from the
query logic, so you can apply the same derived/query method with different fetch profiles. Under
the hood Hibernate still issues joins; the graph is just a cleaner, annotation-driven way to
express the plan — especially handy on Spring Data derived queries.
Rule of thumb: Use @EntityGraph to add eager fetching to Spring Data methods declaratively;
use JOIN FETCH when you're already writing custom JPQL.
cascade propagates entity-manager operations from a parent to its associated children, so you
don't persist/remove each child manually.
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
List<OrderLine> lines;
// em.persist(order) now also persists every line; em.remove(order) removes them too
Common values: PERSIST, MERGE, REMOVE, REFRESH, DETACH, and ALL (all of them).
Cascade naturally fits parent → child composition (an order owns its lines). Be cautious with
REMOVE/ALL on associations to shared entities — cascading a delete to something other
objects reference is a classic bug.
Rule of thumb: Cascade ALL from an aggregate root to the children it owns; never cascade
REMOVE to entities shared across aggregates.
orphanRemoval = true deletes a child when it is removed from the parent's collection —
i.e. when it becomes an "orphan" with no parent reference, even without deleting the parent.
@OneToMany(mappedBy = "order", cascade = ALL, orphanRemoval = true)
List<OrderLine> lines;
order.getLines().remove(line); // orphanRemoval → DELETE FROM order_line WHERE id = ?
cascade = REMOVE only fires when you delete the parent. orphanRemoval additionally fires
when a child is disassociated from the parent. It expresses true ownership: a line can't
exist without its order. Don't enable it on associations to shared entities.
Rule of thumb: Use orphanRemoval = true for owned children that must die when detached from
the parent; cascade REMOVE alone only deletes children when the parent is deleted.
@ManyToMany is backed by a join table of two foreign keys. @JoinTable lets you name the
table and its columns; mappedBy marks the inverse side.
@Entity class Post {
@ManyToMany
@JoinTable(name = "post_tag",
joinColumns = @JoinColumn(name = "post_id"),
inverseJoinColumns = @JoinColumn(name = "tag_id"))
Set<Tag> tags = new HashSet<>();
}
@Entity class Tag {
@ManyToMany(mappedBy = "tags") // inverse side
Set<Post> posts = new HashSet<>();
}
Use a Set, not a List, to avoid Hibernate deleting and re-inserting all rows on change.
And once the relationship needs its own attributes (e.g. a timestamp on the link), stop using
@ManyToMany.
Rule of thumb: Map @ManyToMany with a Set and a @JoinTable; the moment the link carries
extra data, model it as an explicit entity.
Because a raw @ManyToMany join table can hold only the two foreign keys — there's nowhere to
store attributes about the relationship itself (quantity, price, added-date). Promote the link to
a real entity.
@Entity class OrderItem { // the join table becomes a first-class entity
@ManyToOne Order order;
@ManyToOne Product product;
int quantity; // extra columns the relationship needs
BigDecimal unitPrice;
}
Now the two @ManyToMany sides become two @OneToMany-to-OrderItem relationships. You gain a
place for relationship data, predictable SQL, and the ability to query the link directly. Most
real-world "many-to-many" relationships end up here.
Rule of thumb: Use @ManyToMany only for a pure tag-style link; as soon as the relationship
has its own data, model it as an explicit join entity.
It's thrown when you access a lazy association after the persistence context has closed — typically in the controller or during serialization, outside the transaction that loaded the entity.
@Transactional(readOnly = true)
Order load(Long id) { return repo.findById(id).orElseThrow(); } // context closes on return
// later, in the controller:
order.getLines().size(); // ❌ LazyInitializationException — no open session
Fixes, best to worst: fetch what you need inside the transaction (join fetch, entity graph), or map to a DTO while the context is open. Anti-patterns: turning the association EAGER, or relying on Open Session in View to paper over it.
Rule of thumb: Load every association you'll need inside the transactional service method; don't fix lazy exceptions by going eager or leaning on OSIV.
Batch fetching loads lazy associations for many parents in one IN query instead of one per
parent — turning N+1 into roughly N/batchSize+1 queries.
spring.jpa.properties.hibernate.default_batch_fetch_size=50
@BatchSize(size = 50) // or per-association
@OneToMany(mappedBy = "order")
List<OrderLine> lines;
// Hibernate loads lines for up to 50 orders: ... WHERE order_id IN (?, ?, ... 50)
It's the safety net for cases where join fetch doesn't fit (multiple collections, pagination).
The global default_batch_fetch_size is one of the highest-value Hibernate settings — set it and
a whole class of N+1 problems softens automatically.
Rule of thumb: Set default_batch_fetch_size globally as a baseline N+1 mitigation, then add
targeted join fetches where a query needs them.
Because FetchType declares the default loading behavior on the mapping, but the actual
query can override it. Crucially, a LAZY mapping can always be made eager per query,
while an EAGER mapping is hard to make lazy.
@ManyToOne(fetch = FetchType.LAZY) // default lazy...
Customer customer;
// ...but this one query fetches it eagerly:
@Query("SELECT o FROM Order o JOIN FETCH o.customer")
List<Order> withCustomer();
This asymmetry is why the rule is map everything LAZY: you keep the freedom to fetch eagerly where a use case needs it, and you never pay for eager loads where it doesn't. EAGER bakes the cost into every query whether or not you use the data.
Rule of thumb: Map LAZY for flexibility and opt into eager fetching per query; EAGER is a decision you can't easily reverse.
A DTO projection selects only the columns you need into a flat object, so Hibernate never
builds managed entities or touches lazy associations at all — no N+1, no LazyInitialization.
@Query("""
SELECT new com.app.dto.OrderSummary(o.id, c.name, COUNT(l))
FROM Order o JOIN o.customer c JOIN o.lines l
GROUP BY o.id, c.name
""")
List<OrderSummary> summaries(); // returns DTOs, not entities
You can also use a Spring Data interface projection (an interface with getters) for the same effect with less ceremony. Projections are the right tool for read-only views and API responses: less data over the wire, no lazy-loading surprises, and a clean boundary between the persistence model and the API model.
Rule of thumb: For read-only queries and API responses, project straight into DTOs instead of loading entities and their graphs.
More Data Access interview questions
More ways to practice
The self-quiz is live. Join our channel for updates, new content & tech tips.