Entity relationships and fetch types
Real data is connected — a parcel passes through a hub, a hub handles many parcels — and JPA maps those
connections with relationship annotations: @ManyToOne, @OneToMany, @ManyToMany, @OneToOne. Getting
these right, and especially getting fetch types right, is where JPA is most powerful and most
dangerous — the wrong fetch type is the source of the N+1 problem (next lesson) and of surprising
performance. This lesson maps Dakiya's relationships, verified against a running app.
The four relationships, by cardinality
As with any data model, cardinality picks the annotation:
@ManyToOne— many entities point to one. Many parcels belong to one hub. The foreign key lives on the "many" side (the parcel). The most common relationship.@OneToMany— the inverse: one entity has many. One hub has many parcels.@ManyToMany— each side relates to many of the other (via a join table).@OneToOne— exactly one of each.
Dakiya's core relationship: a parcel is currently at one hub; a hub holds many parcels.
@ManyToOne: the foreign key side
The @ManyToOne goes on the entity that holds the foreign key — the parcel:
@Entity
public class Parcel {
// ...
@ManyToOne(fetch = FetchType.LAZY) // the hub is loaded lazily (see below)
private Hub hub;
// ...
}
This creates a hub_id foreign-key column on the parcel table. Verified: setting parcel.setHub(hub) and
saving persisted the foreign key, and parcel.getHub() returned the hub. @ManyToOne is the workhorse
relationship — most connections in a schema are "this belongs to that", modelled as a @ManyToOne on the
owning (foreign-key) side.
@OneToMany: the inverse side, and who owns the relationship
To navigate from a hub to its parcels, add the inverse @OneToMany:
@Entity
public class Hub {
// ...
@OneToMany(mappedBy = "hub") // "the Parcel.hub field owns this relationship"
private List<Parcel> parcels = new ArrayList<>();
}
The critical word is mappedBy. In JPA, one side owns the relationship (the side with the foreign key —
the @ManyToOne on Parcel), and the other side is the inverse, marked mappedBy = "hub" to say "I am
just the mirror; the hub field on Parcel is where the foreign key lives". Get this wrong (omit
mappedBy) and JPA thinks there are two relationships and creates an extra join table — a classic
mistake. The rule: the @ManyToOne side owns the foreign key; the @OneToMany side is mappedBy the
field name on the owning side.
Fetch types: the decision that causes (or prevents) N+1
Every relationship has a fetch type deciding when the related data is loaded, and this is the most consequential setting in JPA:
LAZY— the related entity is not loaded when the owner is loaded; it is fetched only when you first access it.parcel.getHub()triggers a query at that moment.EAGER— the related entity is loaded immediately, always, whenever the owner is loaded.
The defaults, which you must know:
@ManyToOneand@OneToOnedefault toEAGER.@OneToManyand@ManyToManydefault toLAZY.
The strong, near-universal advice: make everything LAZY. Set @ManyToOne(fetch = FetchType.LAZY)
explicitly (overriding its EAGER default), as Dakiya's Parcel.hub does. Why: EAGER means every time you
load a parcel, Hibernate also loads its hub — even when you do not need it — and if the hub has its own EAGER
relationships, they load too, cascading into loading half the database for one parcel. LAZY loads related
data only when you actually use it. EAGER's cost is invisible until it is a performance problem; LAZY puts
you in control. So: default everything to LAZY, and fetch what you need explicitly (with a fetch join —
next lesson) when you know you need it.
The catch with LAZY: the two failure modes
LAZY is right, but it has two consequences you must understand — they are the source of the next lesson:
-
LazyInitializationException. A lazy association can only be loaded while the persistence context is open (inside a transaction, typically). Accessparcel.getHub()after the transaction closed — for example when Jackson serialises an entity outside a transaction — and Hibernate throwsLazyInitializationException("could not initialize proxy — no Session"). This is one more reason to map entities to DTOs inside the transaction (the DTO lesson) rather than serialising entities directly. -
The N+1 problem. Load a list of parcels, then access each one's lazy hub in a loop, and Hibernate fires one query for the list plus one per parcel for the hubs. Verified against the running app with Hibernate's query statistics: iterating 5 parcels and touching each lazy hub ran 6 queries (1 + 5); the same data with a fetch join ran 1. The next lesson is entirely about seeing and fixing this — for now, know that LAZY relationships accessed in a loop are where N+1 lives.
Neither failure means "avoid LAZY" — LAZY is correct. They mean "load lazy data deliberately": map to DTOs inside the transaction (avoiding the exception), and use a fetch join when you know you will access the relationship (avoiding N+1).
Cascading and the owning side, briefly
Two more relationship options you will meet:
cascade— whether operations propagate.@OneToMany(mappedBy="hub", cascade = CascadeType.ALL)means saving/deleting a hub cascades to its parcels. Use cascades deliberately — cascading a delete can remove more than you intend.orphanRemoval— removing a child from the collection deletes it. Powerful and sharp; use with care.
Keep relationships as simple as the domain allows: a @ManyToOne (LAZY) for "belongs to", its @OneToMany
(mappedBy) inverse only when you actually navigate from the one side, and cascades only where the child
genuinely has no life without the parent. That restraint, plus LAZY-by-default, keeps the data layer
predictable — which sets up the N+1 lesson to make it fast.
Check your work
The four relationships. @ManyToOne (FK on the many side — the workhorse), @OneToMany (inverse),
@ManyToMany, @OneToOne — chosen by cardinality. Verified Parcel @ManyToOne Hub persisted the FK.
Owning side and mappedBy. The @ManyToOne side owns the foreign key; the @OneToMany side is
mappedBy = "<field on owner>". Omitting mappedBy wrongly creates an extra join table.
Fetch types and defaults. LAZY (load on access) vs EAGER (load always); @ManyToOne/@OneToOne default
EAGER, @OneToMany/@ManyToMany default LAZY. Make everything LAZY explicitly — EAGER loads data you do
not need and cascades.
LAZY's two failure modes. LazyInitializationException (accessing lazy data after the transaction/
context closed — map to DTOs inside the transaction), and N+1 (verified 6 vs 1 with a fetch join, next
lesson). Both mean "load lazy data deliberately", not "avoid LAZY".
Cascade / orphanRemoval. Propagate operations / delete removed children — use deliberately; a cascading delete can remove more than intended.
Practice
- Add
@ManyToOne(fetch=LAZY) Hub hubtoParcel; save a parcel with a hub and confirm thehub_idforeign key and thatgetHub()returns it. - Add the inverse
@OneToMany(mappedBy="hub") List<Parcel>toHub; then removemappedByand observe the extra join table JPA creates — then put it back. - Leave
@ManyToOneat its EAGER default, load a parcel, and watch (show-sql) the hub load every time; switch to LAZY and confirm it loads only on access. - Access a lazy relationship after the transaction has closed (serialise an entity from a controller) and
reproduce
LazyInitializationException; fix it by mapping to a DTO inside the transaction. - Load a list and touch each lazy hub in a loop; with query statistics, confirm the N+1 count (1 + N), then read ahead to the fetch-join fix.
- Add
cascade = CascadeType.ALLto a@OneToMany, save a parent with children, and confirm the children are saved too — then reason about the risk of a cascading delete.
Official documentation
- Jakarta Persistence — relationships —
@ManyToOne,@OneToMany, etc. - Hibernate — Associations and fetching — Fetch types and the owning side.
- Vlad Mihalcea — The best way to map a @OneToMany — A well-known deep treatment of relationship mapping.
Next: the N+1 problem and how to see it.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship