1. Strategy: from scenario, not annotation
The baseline has already been taken: one and the same scenario “order list + customer.email” showed that SQL does not grow out of thin air. Now the next question matters: is this really a list scenario, or have we already turned it into a half-card for each row without noticing?
Especially at the beginning, it is very tempting to “beat Hibernate” with one setting. That is like thinking, “I’ll use one password "12345" everywhere so I do not have to remember it.” Seems convenient, but the consequences... let us say they become noticeable quickly.
In ORM, a loading strategy is not about the beauty of an annotation, but about the reading contract. In an order list, you usually need a compact set of data: order number, status, total amount, creation date. In an order card, you already need the customer, shipping address, and line items. In a product card, you need ProductDetails, while in a catalog list it is often enough to have sku, name, status, price.
The problem starts when we try to make one “universal graph” at the entity level: “let everything be EAGER, then it definitely will not fail.” That gives you universality at the level of “always wear a winter coat.” You will not freeze — yes, but in summer it will be hard.
In our Commerce Persistence Lab project, this is especially visible: PurchaseOrder is linked to Customer, contains a collection of OrderItem, and Product has ProductDetails. These are natural domain relationships, but the domain reads differently. And if we do not separate them in our heads and in code, we inevitably end up with two extremes: either N+1 (loading “one by one”), or overfetching (loading extra data).
The reading contract: what the method returns
Now comes an important turn. We stop thinking “what is convenient for me to have in the object” and start thinking “what am I obliged to show/return in this scenario”. It is a small change in wording, but it saves you from a huge amount of unnecessary SQL.
Imagine that you write a method printOrderList() for a back-office order table. This method is not “it will somehow print the orders.” It must output (or return) a specific set of fields. If the list contract does not include order items, then any access to order.getItems() inside this method is unnecessary. Not “might come in handy”, but specifically unnecessary: it violates the contract and makes the cost of reading unpredictable.
To make this a little more visual, let us record a simple table: “scenario → which data is actually needed”. This is not about SQL, not about Hibernate, but about meaning.
| Scenario | Example result | Root entity | Which relationships are allowed to be touched |
|---|---|---|---|
| Order list (table) | orderNumber, status, totalAmount, createdAt | PurchaseOrder | usually none; at most, ID-like values through proxies |
| Order card | customer, address, line items | PurchaseOrder | customer, items (and carefully — what is inside items) |
| Product list (catalog) | sku, name, status, price | Product | usually do not touch details |
| Product card | description, specifications, warranty | Product | details (because that is the whole point of the card) |
Pay attention to an important idea: we are not yet discussing how exactly to load these relationships efficiently. We are only honestly recording whether they are needed at all in this scenario. It is like making a shopping list before you rush around the store with a microbus-sized cart.
4. Access pattern: getters change the cost
Hibernate does not read your thoughts. It reads your code. If your code touches a relationship, Hibernate must ensure correct access to the data. And for that, it sometimes needs to execute SQL. At that moment, “loading strategy” turns from an abstraction into a very concrete thing: which getters you call.
This is especially tricky in Java because a getter looks harmless. order.getCustomer() seems like just field access. But in ORM, it can be a “button: run SQL.” And now you have not merely “printed the order number”, but taken Hibernate for a walk to the database and back.
Let us look at a minimal example from our domain. First, the “list” access, where we use only root-entity fields:
// List scenario: we work ONLY with root-entity fields.
// Important: do not touch relationships so as not to trigger unnecessary SQL.
String row = order.getOrderNumber() + " " + order.getStatus();
System.out.println(row); // ORD-2026-0001 NEW
There is no access to relationships here. If we loaded a list of PurchaseOrder, then we work with what is already in the object.
And now one small change — and the scenario is no longer a “list of headers”:
// WARNING: getCustomer() may cause extra loading of the customer (SQL),
// especially if customer is LAZY and we are looping through a list of orders.
String row = order.getOrderNumber() + " " + order.getCustomer().getEmail();
System.out.println(row); // ORD-2026-0001 alice@example.com
From a business-view perspective, this is “we just added an email column.” From Hibernate’s perspective, this is a requirement to prepare the customer data. And if we do this in a loop over a list of orders, we are very close to N+1.
An even trickier case is collections. Sometimes a person thinks: “Well, I am not iterating over items, I am only checking size().” Hibernate responds: “Oh, a collection, I need to initialize it.”
// The catch: for Hibernate, size() on a persistent collection may require SQL.
// That is, it is not a "constant-time operation" like with a regular ArrayList.
int itemsCount = order.getItems().size();
System.out.println(itemsCount); // 3
For JDK ArrayList.size(), this is a constant-time operation. For a Hibernate persistent collection, size() may mean a SQL query. So access pattern is really a “getter contract”, whether you like it or not.
5. Separate reads: list and card
The next step is very practical and almost always gives an immediate effect even without fine-grained optimization: split the read scenarios in code. Do not try to make one method that is both “list”, “card”, and “a little bit of details”. Universal methods in persistence code are usually universal in only one sense: they are universally expensive and unpredictable.
We will not create another demo service next to it. It is simpler and more honest to split the same OrderReadService, which we already measured in the baseline scenario, into two different read contracts: list and card.
Example skeleton of the same order reading service:
package com.example.commerce.orders.query;
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderReadService {
private final EntityManager em;
public OrderReadService(EntityManager em) {
this.em = em;
}
@Transactional(readOnly = true)
public void printOrdersWithCustomerEmail() {
// This is the baseline scenario from the measurement: order list + access to customer.email in a loop.
}
@Transactional(readOnly = true)
public void printOrderList() {
// "List" scenario: the contract must remain compact.
}
@Transactional(readOnly = true)
public void printOrderCard(Long orderId) {
// "Card" scenario: relationships may already be part of the contract here.
}
}
Two things matter here. First, separate methods mean separate contracts and separate expectations for SQL. Second, readOnly = true reminds us that this is a read scenario, not “we accidentally changed something and got an unexpected UPDATE.” We are not going deep into transactions today, but the discipline of readOnly helps keep your head in the right mode.
6. Orders: a list without extra loading
The list scenario is where the cost of a mistake is highest. If an order card reads 5 queries, it is unpleasant, but usually tolerable because there is only one card. If a list of 200 rows turns into 201+ queries, that is already a problem.
Therefore, in the “order list” we must consciously limit ourselves to what is truly a header. For example, let us print only the number and status. In the example below, I deliberately do not touch the customer and items — not even “just to look”.
@Transactional(readOnly = true)
public void printOrderList() {
// Use the same root query, but keep the scenario within list boundaries.
var orders = em.createQuery("select o from PurchaseOrder o", PurchaseOrder.class)
.getResultList();
for (var o : orders) {
// Touch only simple order fields: that is the list contract.
System.out.println(o.getOrderNumber() + " " + o.getStatus()); // ORD-... NEW
}
}
Yes, this looks “too simple.” And that is the point: the simplicity of the scenario is your control. A list must be compact. If the business suddenly says, “We also want the customer email,” that is not a “small request” but a change in scenario. And you should treat it like an engineer: “Okay, now the list is richer, and we must design the read for that.”
Here it is important to distinguish two things. Such a refactor removes accidental traversal: the list no longer goes into customer and items just because someone added a getter in a loop. But it does not cancel the cost of the mapping itself. If the relationship is already forced to EAGER or the default to-one drags in extra loading, the list may still remain expensive even without explicit getters. Then the problem is no longer the access pattern, but the fetch approach itself.
7. Orders: card and data depth
An order card almost always requires relationships: without the customer and line items, it is not a card, but a sad order number in a void. Here we can already touch customer and items, because that matches the card contract.
But even in a card, it is useful to remember the “second-level N+1”. For example, if you start looping through OrderItem and calling item.getProduct().getDetails(), you may get a series of queries for products. That is why the project’s domain model intentionally contains snapshot fields in OrderItem: productNameSnapshot, productSkuSnapshot. An order card can most often show line items without loading the full Product.
Here is a tiny fragment of how you can print a card based on a snapshot rather than loading the product:
// Snapshot fields are a way to show an order item
// without triggering Product/ProductDetails loading for every row.
String line = item.getProductSkuSnapshot() + " x" + item.getQuantity();
System.out.println(line); // SKU-001 x2
And here is an example of the same card service (simplified and short). It shows the idea: a card is allowed to touch relationships, but it does so intentionally:
@Transactional(readOnly = true)
public void printOrderCard(Long orderId) {
// For the card, we read one specific order.
PurchaseOrder o = em.find(PurchaseOrder.class, orderId);
// Simple root-entity fields — the safe part of the card.
System.out.println(o.getOrderNumber()); // ORD-2026-0001
// WARNING: access to customer may perform extra SQL (if LAZY).
System.out.println(o.getCustomer().getEmail()); // alice@example.com
// WARNING: size() on a collection may trigger loading items (SQL).
System.out.println(o.getItems().size()); // 3
}
Even this code can already produce several queries (and that is normal for a card), but at least it honestly matches the scenario. The danger starts when this “card” code is accidentally dragged into a list method “just so it looks nice in the table.”
8. Catalog: product list and product card
A catalog is a classic place where developers either load too much or trigger extra loading too often. In our project, ProductDetails is split into a separate entity not because we like complexity, but because product details are not always needed. In the catalog list, they usually only get in the way, while in the card they are the “heart” of the scenario.
A product list scenario usually looks like this: sku, name, status, price. And if in this scenario you went into product.getDetails().getDescription(), you turned the list into a series of detail lookups, that is, potentially into N+1.
Mini example of a catalog list:
// "Catalog list" scenario: product details are usually not needed.
// Therefore, do not touch product.getDetails() in this path.
String row = product.getSku() + " " + product.getName();
System.out.println(row); // SKU-001 Coffee Grinder
A product card is a different story. Here, access to details is justified by the meaning of the card itself, so “more expensive” reading is expected; the main thing is that it remains controlled:
// "Product card" scenario: details are part of the contract,
// so access to them here is logical (and can be "more expensive" as expected).
String card = product.getName() + ": " + product.getDetails().getDescription();
System.out.println(card); // Coffee Grinder: Compact burr grinder...
Notice: we are not yet choosing the technical way “how to do this with one query” or “how to optimize it.” Right now we need something else: do not force the list to live by the card’s rules. That is use-case driven fetching at the level of thinking, before any tools.
9. Algorithm for choosing a loading strategy
It is tempting to get the “right strategy” as a ready-made recipe. But in reality, the recipe starts with discipline. The good news is that it is fairly mechanical, and you can apply it even with limited experience — if you honestly follow the steps and do not replace them with “maybe it will work out.”
First, you name the read scenario and formulate its result. Not “get the orders”, but “get the rows for the order table”. Then you list which fields are actually needed. After that, you look at the code and check: which getters are called, which relationships are touched. Then you measure the baseline query count. And only then do you decide what to do: maybe simply removing access to the relationship from the list path is enough, and the problem will disappear at that step already.
To make it easier to keep in your head, here is a flow diagram (in the style of “if you follow it, you are less likely to step on a rake”):
flowchart TD
A["Name the read scenario"] --> B["Define the result: which fields are needed"]
B --> C["Check the access pattern: which getters are actually called"]
C --> D["Take a baseline: SQL trace + query count"]
D --> E{"Is the scenario a list?"}
E -->|yes| F["Remove unnecessary relationship access from the list"]
E -->|no, card| G["Allow the needed relationships, but consciously"]
F --> H["Repeat the measurement on the same dataset"]
G --> H["Repeat the measurement on the same dataset"]
This diagram does not promise “always one SQL query.” It promises something more important: you will stop being surprised by what happens, because you have already described ahead of time what the scenario does.
And here arises the next engineering question. If, after such an audit, you see that the relationship really is part of the reading contract, then it is no longer enough to just “not touch it in the list.” You need to decide which fetch approach to use to load this data without unnecessary cost.
10. Typical mistakes when choosing a strategy
Mistakes in fetching design usually happen not because a person is a “bad developer,” but because the brain tries to simplify the world: to make one universal solution. ORM does not get back at you angrily; it gets back at you tediously and expensively — with a large number of SQL queries. Below are several mistakes that are most often seen exactly at the stage when you already know about N+1, but still have not learned to design reading as a scenario.
Mistake #1: design reading from the entity structure, not from the result.
If you start thinking, “an order has a customer, so the order list must show the customer,” you are effectively handing UI design over to the structure of tables and relationships. The correct order is the reverse: first the list decides what it shows, and only then do you decide what to read for that. Sometimes it turns out that the customer is not needed in the list at all, and you save yourself dozens of queries without a single optimization.
Mistake #2: mixing list and card in one method.
This is a very common practice: “I made a printOrders() method, and it prints the order number, customer email, and number of items.” On a small dataset, this may look “normal.” At the real table size, it turns into a combine that always runs in “card for every order” mode. As a result, you pay for depth even where it is not needed, and you get unpredictable query growth.
Mistake #3: assuming that ‘I did not use the field in code’ means ‘I did not pay for it’.
In Java, it is easy not to notice that a relationship was touched indirectly: through toString(), through logging, through string concatenation, through collection size(). ORM does not distinguish between “I only wanted to look” and “I wanted a business operation” — it sees access to data and tries to ensure correctness. Therefore, in read scenarios it is useful to be paranoid in a good sense: know which getters are actually called.
Mistake #4: estimate the cost of reading on one object and carry that conclusion over to a list.
One order, one card — almost always “tolerable.” But a list is different math. There, any extra loading becomes a linear dependency on the number of rows. So all conclusions about fetching must be checked exactly in a scenario where there is size (dozens/hundreds of rows), otherwise you are training not engineering, but optimism.
Mistake #5: try to “optimize” immediately without fixing the scenario and the baseline.
When a person hears N+1, they often start chaotically changing settings, adding “useful” annotations, and rewriting queries. Without a clear scenario and measurement, this becomes a lottery: you are not sure you fixed exactly what was broken, and you cannot prove improvement. It is much more reliable to first lock down the scenario contract and the baseline query count, and then change one thing at a time.
GO TO FULL VERSION