Where most JPA performance bugs live
Mapping a single entity is easy. The trouble starts when entities reference each other, because now every load is a question: do we fetch the associated rows now, later, or never — and in how many queries? Get this wrong and a page that should run one query runs a hundred. This article is about modeling associations correctly and, more importantly, controlling how they load.
The four association types
@ManyToOne Customer customer; // many orders → one customer (holds the FK)
@OneToMany List<OrderLine> lines; // one order → many lines
@OneToOne Cart cart; // one user → one cart
@ManyToMany Set<Tag> tags; // many ↔ many, via a join table
The single most important fact: the foreign key always lives on the @ManyToOne side, regardless of
which direction your code navigates. Most relationships are best modeled from the child holding the FK, so
@ManyToOne is the one you'll reach for most.
Owning vs inverse: the source of "my FK is null"
In a bidirectional association, one side owns the foreign key and the other is a mappedBy mirror.
Hibernate persists from the owning side only:
@Entity class Order {
@OneToMany(mappedBy = "order") // INVERSE — ignored at flush if changed alone
List<OrderLine> lines = new ArrayList<>();
}
@Entity class OrderLine {
@ManyToOne // OWNING — holds the order_id FK
Order order;
}
Add a line only to order.lines without setting line.order, and the FK column stays null — because the
inverse side doesn't drive persistence. The fix is a helper that synchronizes both sides:
void addLine(OrderLine line) {
lines.add(line);
line.setOrder(this); // set the owning-side FK
}
Make correct usage the easy path and this class of bug disappears.
LAZY vs EAGER, and why everything should be LAZY
Fetch type controls when an association loads. The defaults are a trap: @ManyToOne and @OneToOne
default to EAGER, while @OneToMany/@ManyToMany default to LAZY.
@ManyToOne(fetch = FetchType.LAZY) // override the eager default
Customer customer;
An eager to-one means loading one entity quietly drags in its parents; multiply that across a list and you have a query storm before you've written a single explicit fetch. The discipline that pays off: make everything LAZY and fetch what each use case needs per query. A lazy mapping can always be made eager for one query; an eager mapping can't easily be made lazy — the asymmetry is the whole argument.
The N+1 problem
This is the bug the fetch strategy exists to prevent:
List<Order> orders = repo.findAll(); // 1 query
for (Order o : orders) {
o.getCustomer().getName(); // +1 query PER order
} // 100 orders → 101 queries
It shows up with lazy associations (loaded on access in the loop) and eager ones alike (a separate select per parent). It's usually invisible until you watch the SQL log — which is the first thing to do when a list endpoint is mysteriously slow.
Fixing N+1: JOIN FETCH and entity graphs
The direct fix is to load the association in the same query:
@Query("SELECT o FROM Order o JOIN FETCH o.customer WHERE o.status = :s")
List<Order> findByStatusWithCustomer(@Param("s") Status s);
JOIN FETCH is perfect for to-one and a single collection. It has two limits: fetching two collections
at once creates a Cartesian product, and combining it with pagination forces Hibernate to paginate in
memory. For Spring Data methods, an @EntityGraph expresses the same plan declaratively:
@EntityGraph(attributePaths = {"customer", "lines"})
List<Order> findByStatus(Status status);
The graph keeps the fetch plan separate from the query, so it's reusable across derived methods. And as a
global safety net, set hibernate.default_batch_fetch_size=50 — Hibernate then loads lazy associations for
many parents in one IN query, softening a whole class of N+1 problems automatically.
Cascade and orphanRemoval
cascade propagates entity-manager operations from parent to children; orphanRemoval deletes a child when
it's removed from the parent's collection:
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
List<OrderLine> lines;
order.getLines().remove(line); // orphanRemoval → DELETE that line
The distinction: cascade = REMOVE fires when you delete the parent; orphanRemoval additionally
fires when a child is disassociated. Both express ownership — an order line can't exist without its
order. The danger is cascading REMOVE/ALL (or orphan removal) to shared entities that other
aggregates reference; that's how a delete quietly takes out rows other code still needs. Cascade from an
aggregate root only to the children it truly owns.
When @ManyToMany stops being enough
A raw @ManyToMany join table holds only two foreign keys — there's nowhere for relationship data:
@Entity class OrderItem { // the join table becomes a real entity
@ManyToOne Order order;
@ManyToOne Product product;
int quantity; // attributes the relationship needs
BigDecimal unitPrice;
}
The moment the link needs its own columns (quantity, price, added-date), promote it to an explicit entity
and turn the two @ManyToMany sides into two @OneToMany-to-OrderItem relationships. Reserve plain
@ManyToMany for pure tag-style links — and even then, use a Set, not a List, so Hibernate doesn't
delete and re-insert the whole join table on every change.
LazyInitializationException and DTO projections
Access a lazy association after the persistence context has closed — in the controller, during
serialization — and you get a LazyInitializationException. The right fix is to load what you need inside
the transaction (join fetch, entity graph), not to make the association eager or lean on Open Session in
View. Better still for read-only views, skip entities entirely and project into a DTO:
@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();
No managed entities, no lazy associations, no N+1 — just the columns the API renders.
Recap
Model relationships from the @ManyToOne side that holds the FK; synchronize both sides of bidirectional
links; make every association LAZY and fetch per query with JOIN FETCH or @EntityGraph; watch the SQL
log for N+1 and set a global batch fetch size; cascade and orphan-remove only to owned children, never
shared ones; promote @ManyToMany to a join entity once the link has data; and for read-only views,
project into DTOs instead of loading whole graphs. Fetching is where JPA is won or lost.