Skip to content

Relationships & Fetching Interview Questions & Answers

15 questions Updated 2026-06-26 Share:

JPA associations and fetch strategies in Spring Boot — @OneToMany, @ManyToOne, @ManyToMany, owning vs inverse side, LAZY vs EAGER, the N+1 problem, join fetch, entity graphs, and cascade types.

Read the in-depth guideJPA Relationships and Fetching in Spring Boot, Explained(opens in new tab)
15 of 15

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 ways to practice

The self-quiz is live. Join our channel for updates, new content & tech tips.

Join our WhatsApp Channel