1. The right goal when fixing lazy loading
When an application crashes with LazyInitializationException, an especially tired brain suggests quick, duct-tape-level solutions like “just stick some tape on it and pretend it was always like that.” That is normal: it happens to everyone. But in the persistence layer, such “quick wins” often turn into slow defeats: SQL starts happening in unexpected places, hard-to-explain delays appear, and the code stops being predictable.
A proper fix starts with changing the goal. We are not “removing the exception” as a symptom; we are making data reading part of a complete service operation, inside which the transaction lives and the persistence context is available. Then lazy mechanics become just a tool for saving work, not a trap. And the nicest part: SQL once again “lives” where it belongs — next to the code that defines the use case.
If you say it in one sentence, lazy loading is like food delivery: while the restaurant is open (there is an active session), you can order one more dish; when the restaurant is closed (the transaction is over), trying to “order more” is useless and a little sad.
After talking about no Session and the temptation to simply keep the Session alive longer, the picture is already fairly honest. The normal fix is not in the web layer and not in a configuration flag, but in the very shape of the service operation: reading must finish while Hibernate still has a working persistence context. So we are going to fix not the symptom, but the reading boundary.
Identify the data needed for reading
The most common logical mistake is to think that if somewhere a “purchase order” is needed, then the whole PurchaseOrder must be returned. If somewhere a “product” is needed, then the whole Product must be returned. That is convenient... until the first lazy association. And then we end up with an entity that looks “real,” but inside it contains “not loaded yet, please come back with an active transaction.”
So before writing code, it is useful (and, most importantly, cheap in terms of time) to answer the question: what exactly is needed on the outside? Not “which entity,” but “which fields/values.” For example, “for a product card, we need the description string” or “for a short order summary, we need the order number and item count.” And then it suddenly turns out that returning an entity at all is not necessary.
Below is a small cheat-sheet table for thinking about it (this is not dogma, just a convenient way to formulate the requirement):
| Reading scenario (use case) | What is actually needed outside | Where it is best to prepare this | What happens if you return an entity and hope for lazy loading |
|---|---|---|---|
| Product card | sku, name, description from ProductDetails | In CatalogService inside @Transactional(readOnly=true) | product.getDetails().getDescription() may fail outside a transaction |
| Order summary | orderNumber, itemCount | In OrderQueryService inside a transaction | order.getItems().size() may fail outside a transaction |
| List of item names | a list of productNameSnapshot | In the service: read order.getItems() and build a list | in the controller/external code, iterating over the collection will easily trigger an exception |
Notice the common pattern: we are not arguing with lazy loading as a phenomenon. We are simply moving the moment of data access to where Hibernate is allowed (and able) to do SQL.
3. Reading boundary: service + @Transactional(readOnly=true)
It is very tempting to think of @Transactional as “an annotation for writes,” and to consider reads “safe” and “not requiring a transaction.” In Hibernate, that is a dangerous illusion: lazy loading does not require moral permission, but a concrete fact — the session must be open. And in a typical Spring application, the session lives precisely within the transaction scope of a service method.
So the correct reading boundary looks like this: external code (a controller, console command, test, or another service) calls one service method, and that method, inside a transaction, retrieves all the needed data and returns a ready result. And readOnly = true here is useful not as a “turbo speed boost,” but as a declaration of intent: “I am reading and do not plan to change anything.” This reduces the risk of accidental changes to a managed entity (and we remember that dirty checking can cause surprises).
Schematically, the correct flow can be represented like this:
flowchart TD
A["External code (controller/test/command)"] --> B["@Transactional(readOnly=true) Service method"]
B --> C["EntityManager.find(...)"]
B --> D["Access to lazy relations inside the transaction (proxy/collection initialized)"]
B --> E["Building the result (String/DTO/List)"]
E --> A
The key point: we do not “drag” a partially initialized entity outside while hoping it will somehow magically load the missing data on its own. We immediately return what is actually needed.
4. Return a value, not an entity: example with ProductDetails
Sometimes fixing a lazy-loading problem is literally a one-line change... if you first acknowledge that the outside world does not need the whole object. Imagine that UI/external code wants to show only the description of a product. Returning Product for one line is like renting a truck to deliver one bun. Technically it works, but it looks suspicious.
It does not matter much which lazy to-one association you have in your project — customer, details, or something else. The principle is the same: the needed data should be read inside the transaction, and a ready result should be returned outside.
In Commerce Persistence Lab, this can be implemented as a read method in the catalog service. Notice that we deliberately read the description inside the transaction. Even if details is a lazy proxy, it will have time to initialize “in place.”
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CatalogReadService {
private final EntityManager entityManager;
public CatalogReadService(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional(readOnly = true)
public String loadProductDescription(Long productId) {
// Important: the entity is read inside the transaction, so the persistence context is available
Product product = entityManager.find(Product.class, productId);
// Important: we access the lazy relation right here, inside the transaction (not somewhere outside)
return product.getDetails().getDescription();
}
}
Here it is important to understand two things. First, product.getDetails() does not have to execute SQL immediately: it may just be a reference/proxy. Second, getDescription() is already a “real data access,” and it is precisely this call that can trigger a SELECT. But we do it where Hibernate has an open session, so there will be no problem.
If you need several fields, the idea does not change: “we loaded the entity, touched the needed lazy fields inside the transaction, and returned ready values outside.”
5. Build a small DTO inside the transaction: OrderSummary example
A slightly more realistic scenario: external code wants not “the whole order,” but a short summary. For example, the order number and item count. This is a typical case where returning an entity is inconvenient: first, it drags along the lazy collection items; second, outside code may start getting tempted to “dig a little deeper” and accidentally trigger SQL outside the transaction.
A simple DTO works well here. In Java 25 (and in modern Java in general), it is convenient to use a record: compact and readable.
// DTO for a short summary: only what the use case actually needs goes outside
public record OrderSummary(String orderNumber, int itemCount) {
}
Now the service method. Important: order.getItems().size() is not a “cheap operation by default.” For a lazy collection, it may be the moment of initialization. Therefore, we must do it inside the transaction.
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderQueryService {
private final EntityManager entityManager;
public OrderQueryService(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional(readOnly = true)
public OrderSummary loadOrderSummary(Long orderId) {
// Read the order inside the transaction
PurchaseOrder order = entityManager.find(PurchaseOrder.class, orderId);
// Important: size() may initialize the lazy items collection (that is, execute SQL)
int itemCount = order.getItems().size();
// Return a DTO: external code can no longer "accidentally" touch lazy relations
return new OrderSummary(order.getOrderNumber(), itemCount);
}
}
Technically, everything here is very human: the service read the data, prepared the result, and returned it outside. External code can no longer accidentally “touch” the lazy collection — it received a number, not a potential mine.
6. Initialize lazy relations in the service
In an ideal world, DTOs and primitives go outside, while entity objects remain within the data and service layers. In reality, you may sometimes find yourself in an intermediate stage: the code is already written in such a way that an entity is returned outside, but you want at least to make the behavior predictable and avoid a crash. This is not “the best style forever,” but as a transitional solution it does happen.
The simplest way to “initialize a collection” is to touch it inside the transaction. For example, call size() or iterate through it. Yes, it looks a bit like a ritual (Hibernate sometimes likes such rituals), but the meaning is rational: you force the ORM to execute the needed SQL where it is allowed to do so.
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderLoadService {
private final EntityManager entityManager;
public OrderLoadService(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional(readOnly = true)
public PurchaseOrder loadOrderInitialized(Long orderId) {
// Read the order inside the transaction
PurchaseOrder order = entityManager.find(PurchaseOrder.class, orderId);
// Initialize the lazy collection: force the ORM to load items here, not somewhere "outside"
order.getItems().size();
// Return the entity as a transitional solution (DTOs are better, but sometimes this is necessary)
return order;
}
}
The plus side of this approach is that it really removes LazyInitializationException for the specific items relation. The downside is that it does not solve the problem systematically: the calling code can still reach for another lazy relation (for example, order.getCustomer().getEmail()), and everything will happen again. So treat this as “localized disarming,” not as the final architecture.
Sometimes developers try to do “even more explicit magic” and use Hibernate-specific methods. For example, like this:
import org.hibernate.Hibernate;
// Initialization through the Hibernate API: works, but binds the code to Hibernate
Hibernate.initialize(order.getItems());
This works according to the same idea (initialize inside the transaction), but it binds the code to the Hibernate API. In a training project, we try to stay closer to the familiar JPA mindset, so we more often show the “ordinary” collection access as the trigger.
7. End-to-end example: reading order details
Now let us put everything together in the most practical scenario possible, close to what is actually needed in Commerce Persistence Lab. Suppose external code (a controller, a test, a console command) needs to show “order details”: the order number and the items. We do not need the entire entity graph, we do not need to return OrderItem as an entity, and for items we especially benefit from snapshot fields already stored in OrderItem (for example, productNameSnapshot, productSkuSnapshot).
Let us create two small DTOs:
// DTO for one order line: snapshot fields are used so we do not drag related entities along
public record OrderItemLine(String productSku, String productName, int quantity) {
}
import java.util.List;
// "Order details" DTO: order number + ready list of lines (no persistent collections or proxies)
public record OrderDetailsDto(String orderNumber, List<OrderItemLine> items) {
}
And a service that initializes items inside the transaction, iterates over them, and builds the DTO. The important thing here is that we use snapshot fields specifically, so we do not provoke additional lazy reads of related entities.
import jakarta.persistence.EntityManager;
import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderReadFacade {
private final EntityManager entityManager;
public OrderReadFacade(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional(readOnly = true)
public OrderDetailsDto loadOrderDetails(Long orderId) {
// Find the order inside the transaction (while the "restaurant is open")
PurchaseOrder order = entityManager.find(PurchaseOrder.class, orderId);
// Build the DTO inside the transaction so we do not leak lazy objects outside
List<OrderItemLine> lines = new ArrayList<>();
for (OrderItem item : order.getItems()) {
// Important: take snapshot fields from OrderItem so we do not trigger lazy loading of Product
lines.add(new OrderItemLine(
item.getProductSkuSnapshot(),
item.getProductNameSnapshot(),
item.getQuantity()
));
}
// Return the already complete result of the use case
return new OrderDetailsDto(order.getOrderNumber(), lines);
}
}
What did we get in practice? External code receives an object that is already fully ready to use: there are no lazy proxies, no persistent collections, and no risk of “accidentally touching it and crashing.” And if Hibernate needs to go to the database for items, it will do so inside the transaction, in the service, in a clear place.
This is the central idea of the lecture: reading data is part of the use case. If you are building a read operation, it should end with a “ready result” going outside, not with “an object that may theoretically read more data later if it is magically positioned correctly in spacetime.”
8. Verifying the fix: SQL and initialization
After the fix, you want to be sure not only that “the error is gone,” but also that SQL has become predictable. Because it is possible to “fix” something so that it stops crashing, but starts silently making queries in unexpected places — and then you have simply traded one pain for another (usually a more expensive one).
The first and most honest way is to look at the SQL log in the sql-trace profile and make sure the queries are executed during the service method, not after it. In the persistence layer, SQL visibility is a mandatory part of thinking, so this is not an “extra option,” but a working tool.
The second way is to check specifically whether a relation has been initialized when you expect it to be. Hibernate provides the utility Hibernate.isInitialized(...). It should not become “business logic,” but for laboratory understanding of the mechanics, it is useful.
import org.hibernate.Hibernate;
// Diagnostic check: has the collection been initialized? (inside a transaction this is especially revealing)
boolean initialized = Hibernate.isInitialized(order.getItems());
System.out.println("items initialized = " + initialized); // items initialized = true/false
The meaning of this check is simple: if inside the transaction you have already iterated over order.getItems(), then initialized should become true. If you did not touch the collection, then it will most likely be false. And then you can easily understand why everything crashes when accessed from the outside.
The most important criterion for a “proper fix” sounds like this: after the fix, you can answer the question “where exactly does data reading live.” If the answer sounds like “inside the service operation,” then you have done what you wanted.
Here it is important to separate two tasks. We have already fixed correctness: data is read in the right place and does not crash code outside the transaction. But that does not automatically make the read efficient: even within the correct service boundary, you can still create too many queries if the fetch plan is chosen poorly.
9. Typical mistakes when fixing lazy problems
Mistake #1: enable OSIV or switch associations to EAGER so that “it does not crash.”
In the short term, that is indeed easier. In the long term, you lose control over when and why SQL is executed, and the application turns into a quest called “find the place where five extra queries suddenly happened.”
Mistake #2: return an entity from the service and assemble the DTO outside (in the controller or “formatter”).
It looks harmless (“I am just building a response”), but this is exactly when you access lazy relations after the transaction has ended. The fix here is almost always the same: move result construction inside the transaction, that is, inside the service method.
Mistake #3: “fix” one LazyInitializationException by initializing one collection with size(), and then continue returning the entity outside.
This creates an illusion of control: today you pre-initialized items, tomorrow someone reached into customer, the day after tomorrow into details, and the problem came back to life. This style quickly turns into a game of “guess which fields still need to be touched in advance.”
Mistake #4: accidentally modify a managed entity in a read scenario.
If you forgot readOnly=true, or somebody called a setter inside a read operation “just for formatting,” Hibernate may decide that this is a real change and send an UPDATE. Then you fix the lazy-loading error and get a surprise database write instead.
Mistake #5: catch LazyInitializationException and “swallow” it in an exception handler.
This almost always leads to broken output data: some fields are empty, some are invalid, and the user sees strange results. In the persistence layer, the exception is useful because it honestly says: “you are reading data in the wrong place.” If you silence it, you simply lose the signal about the bug and continue living with the wrong architecture.
GO TO FULL VERSION