CodeGym /Kursy /Hibernate deep-dive /EAGER, OSIV and repositories

EAGER, OSIV and repositories

Hibernate deep-dive
Poziom 29, Lekcja 2
Dostępny

1. Control: EAGER, OSIV, repositories

EAGER, OSIV, and reflexive repository calls are united by one thing: they all hand over control of data shape, SQL timing, and the work boundary not to the use case, but to the infrastructure. On the outside, this looks like simplification; on the inside, it turns the system into a lottery: the same method can behave differently with a different data size, a different call order, or “harmless” logging.

After entity leakage, oversized transaction, and chatty repository, this is the next layer of the problem. You can clean up an individual service, but if the loss of control is baked into EAGER, open-in-view, or the habit of trusting any CRUD method, SQL will still end up in an unexpected place.

Hibernate is a very powerful thing, but it is not a mind reader. It does not know which screen you are serving right now: a list of 50 orders or a single order card, a CSV export or a background status check. When we enable EAGER “just in case,” when we leave OSIV on “so it does not break,” when we call saveAndFlush() “to make sure it is saved” — in practice, we are saying: “Dear infrastructure, please invent the architecture for me.” And, like any good genie in a bottle, the infrastructure follows the request literally. Then we are surprised that the wish was phrased strangely.

In the end-to-end project Commerce Persistence Lab, this is especially visible: the domain looks simple (products, orders, stock), but there are enough relations that any “universal” configuration turns into a snowball of queries. And the worst part is that the snowball grows in a place you do not expect.

2. EAGER by default: “everything at once”

EAGER looks like a very human idea: “If I need this relationship often, let’s load it right away so there are no surprises later.” It is like taking the entire refrigerator on a picnic because “maybe we will want cheese, sausage, sauce, and maybe even a watermelon.” Formally, you are ready for anything. Practically, you are now carrying a refrigerator.

It is important to remember the basic mechanics: fetch = EAGER does not mean “Hibernate must do a JOIN,” but rather “by the time the entity is considered loaded, the relationship must be available without additional lazy access.” How exactly this is achieved is the provider’s decision. And that is where the magic begins... not always the kind that helps.

JPA has another unpleasant “gift”: @ManyToOne and @OneToOne are EAGER by default. So even if you did not write the word EAGER, it is already there, just wearing a gray cloak and a hood. That is why a good engineering default in a Hibernate-heavy project is to make to-one relationships LAZY explicitly and raise the needed fetch plan at the level of a specific use case.

Look at the typical point where everything starts. Suppose a developer gives the order a “convenient” customer field and decides “well, let’s just always load it”:

package com.example.commerce.orders.entity;

import jakarta.persistence.*;

@Entity
public class PurchaseOrder {

    // Important: this is a "global" decision at the mapping level,
    // it will affect lists, exports, and any reads of this entity.
    @ManyToOne(fetch = FetchType.EAGER) // looks like "to make it easier"
    private Customer customer;

    // In practice, it is usually safer to make to-one relationships LAZY,
    // and raise the needed data shape with a query/EntityGraph for a specific use case.
}

With small datasets, this is almost always “fine.” But then you build an order list. And then the tricky part appears: an order list is not a “I need the whole customer” scenario, it is a “I need the order number, status, amount, and maybe the customer’s email” scenario. And that “maybe” is the key word. EAGER cannot understand “maybe.” It only understands “always.”

Then you enable the SQL trace and see that “always” can look different from one case to another. Sometimes Hibernate really does a join. Sometimes it does a series of secondary selects. Sometimes it does both, because the provider chooses a compromise between join width and number of queries. And most importantly: you are already not controlling this at the use-case level, because you have fixed the behavior in the mapping forever.

To pin the idea down without philosophy, here is a useful table that helps you “sober up” after seeing the word EAGER:

Approach What you are actually fixing Where it hurts Why it is an anti-pattern as a default
fetch = EAGER in entity A global “always load” rule Everywhere: lists, background jobs, any reads One scenario dictates the cost for all scenarios
fetch = LAZY + explicit fetch plan (JOIN FETCH / EntityGraph) A local “load here and now for this case” rule Only where you explicitly chose it Control stays with the use case
Projection (DTO/record) “Read only the needed columns” Where you wanted a “universal entity” But giant graphs and dirty checking overhead disappear

Notice: there is no “right forever” in this table. There is only “right for the task.” EAGER as a tool exists, but as a default mindset it almost always turns into a tax on every query.

Another classic problem with EAGER: it seems to promise “no lazy loading,” but in practice it often just moves queries somewhere else. And moving is not solving. It is like saying: “I will not procrastinate in the evening, I will procrastinate in the morning.” The work still is not done, it is just that coffee has not kicked in yet.

3. EAGER and secondary selects

Here it is useful to pause for a moment and carefully explain the term secondary select in plain language. A secondary select is when you expect that “everything loaded in one query,” but in reality Hibernate first loads the root entity and then performs additional queries to load EAGER relationships. It may look harmless — “just one more SELECT” — until it becomes “one more SELECT for every row in the list.”

Imagine you are doing a “normal” order read:

package com.example.commerce.orders.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderReadService {

    // The transaction here sets the expected Unit of Work boundary for reading.
    // Inside it, Hibernate is allowed to lazily initialize relationships (if they are LAZY).
    @Transactional(readOnly = true)
    public PurchaseOrder loadOrder(Long id) {
        // findById() returns real data (unlike reference/proxy).
        return orderRepository.findById(id).orElseThrow();
    }
}

If customer is EAGER for you, then in the SQL log you may see something like this (simplified, but correct in spirit):

-- 1) Load the order itself
select o.id, o.order_number, o.customer_id
from purchase_order o
where o.id = ?;

-- 2) Load the EAGER customer with a separate query
select c.id, c.email, c.first_name, c.last_name
from customer c
where c.id = ?;

Why not a JOIN? Because Hibernate chooses an execution strategy based on its internal rules, query specifics, already-loaded context, and sometimes simply because “that is safer for correctness and duplicate reduction.” And yes: this decision can differ between different queries and different access patterns.

On lists, this is especially noticeable. When you ask for “50 orders,” a secondary select becomes “50 customers.” And now you have the classic N+1, only it came not from LAZY, but from “the saving EAGER.” And that is psychologically very frustrating: you enabled EAGER so that “there would be no loading problems,” and got a loading problem anyway, just with a different aroma.

By the way, on @OneToMany enabling EAGER is usually even more fun: you risk bloating the result set, getting duplicate root entities, breaking pagination, and sometimes hitting limitations on fetching collections simultaneously. That does not mean EAGER is “forbidden by law.” It means that setting it “by default and everywhere” is like replacing car seat belts with concrete pillows. Formally “safer,” but you will stop being able to drive.

4. OSIV: the persistence context lives too long

OSIV (Open Session in View) is a very old and very tempting pattern. It makes the EntityManager (and therefore the persistence context) live not only inside the service transaction, but almost the entire web request. And then access to LAZY relationships in the controller, JSON serializer, logging, and other “external” places suddenly starts working. People see: “the error is gone” — and check the box “fixed.”

The problem is that OSIV usually does not fix, it masks. It blurs the responsibility boundary: instead of “the service decided which data is needed,” we get “data is loaded wherever someone happened to call a getter.” That turns SQL into an uncontrolled side effect.

In Spring Boot, this looks roughly like this. Enabling OSIV (in practice, this usually means “we left the default”) can be seen in the config:

spring:
  jpa:
    open-in-view: true

Now compare the request lifecycle without OSIV and with OSIV. The diagram is simplified, but emotionally very accurate:

flowchart TD
    A[HTTP request] --> B[Controller]
    B --> C["Service @Transactional"]
    C --> D[Repository / Hibernate]
    D --> C
    C --> B
    B --> E[JSON serialization / logging]
    E --> F[HTTP response]

    subgraph "Without OSIV (open-in-view=false)"
      C1["Persistence context lives only inside @Transactional"]:::good
    end

    subgraph "With OSIV (open-in-view=true)"
      O1["Persistence context lives until the end of the web request"]:::bad
    end

classDef good fill:#e6ffed,stroke:#2f855a,color:#22543d;
classDef bad fill:#fff5f5,stroke:#c53030,color:#742a2a;

What actually changes for the developer? The “place where it is acceptable to think about data” changes. Without OSIV, you are forced to design reading properly: either load the needed graph in the service or return a projection. With OSIV, you can “hope” that everything will be loaded later by itself.

And the worst part: with OSIV you often learn about the problem not when it appears, but when it becomes very expensive. For example, on test data everything was fast. In production, the data grew, serialization started traversing more fields, and you suddenly got dozens of queries per response. And that can happen “after release,” not “at the moment the code was written.”

In our course and project, the baseline is open-in-view=false not out of spite, but because it is the only honest way to keep the transaction boundary and the fetch design in one place: the use case.

5. Blind faith in JpaRepository

Spring Data repositories are a great thing. But exactly like a Swiss Army knife: it is handy until you try to build a house with it. JpaRepository gives you CRUD and several convenient abstractions, but it does not give you the main thing: a meaningful read/write contract for a specific scenario.

“Blind faith in repositories” usually looks like this: “We have findById()/findAll(), so we already have a data layer.” And then people wonder why the order list got slow, why N+1 appeared, why a random saveAndFlush() changed SQL timing, why getReferenceById() suddenly shot them in the foot.

Let us look at one very recognizable fragment. A developer changes a product name and reflexively “confirms the save” with saveAndFlush():

package com.example.commerce.catalog.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ProductService {

    @Transactional
    public void renameProduct(Long id, String newName) {
        Product p = productRepository.findById(id).orElseThrow();

        // Changing a managed entity will be caught by dirty checking on commit.
        p.setName(newName);

        // flush "right now" is almost never needed for a simple rename.
        // It changes the timing of SQL submission and can make behavior less predictable.
        productRepository.saveAndFlush(p); // reflex "just in case"
    }
}

The problem here is not that the code “does not work.” It works. The problem is that you introduce extra semantics into the use case: you force flush to happen right now. And flush is not “confirmation”; it is a change in the moment when Hibernate sends SQL. Sometimes that adds unnecessary load. Sometimes it causes unexpected flush-before-query behavior. Sometimes it makes diagnosis harder because SQL leaves “earlier than expected.” And all of that is just for the habit of “what if it does not save without save,” even though dirty checking already does the job.

Another common story is the desire to “optimize reading” with getReferenceById(), because “it does not do a SELECT.” And this is how it goes:

package com.example.commerce.orders.service;

import org.springframework.transaction.annotation.Transactional;

public class OrderReadService {

    @Transactional(readOnly = true)
    public PurchaseOrder loadOrderRef(Long id) {
        // Important: this is not the "order data", but a proxy to the entity.
        // The SELECT will happen on the first field access (proxy initialization).
        return orderRepository.getReferenceById(id); // proxy, not data
    }
}

This is fine when you truly understand what you are doing. But a “blind” replacement of findById() with getReferenceById() often means: “I am not designing the data shape, I am just hoping it will get faster.” Then either LazyInitializationException shows up (if OSIV is off), or everything “seems to work” (if OSIV is on), but SQL starts happening in an unexpected place because the proxy is initialized on the first access to the data.

And finally, repositories cannot read your use case’s mind. If you do:

package com.example.commerce.catalog.service;

import org.springframework.transaction.annotation.Transactional;

public class CatalogExportService {

    @Transactional(readOnly = true)
    public List<Product> exportAll() {
        // findAll() returns an entity graph, not a "ready export format".
        // Outside this method, relationship traversal can easily begin and trigger N+1.
        return productRepository.findAll(); // "we will sort it out later"
    }
}

...then “later” usually means that somewhere outside, calls to getDetails(), getAssignments(), getCategory(), and other graph traversals begin. And that brings us right back to the beginning of the day: entity leakage + giant graphs.

A repository is a tool. It does not remove the need to decide which data is needed, where the transaction boundary is, and what fetch plan is acceptable. If you use a repository as a “universal button,” at some point it will become a universal “make it unexpectedly expensive” button.

6. Combo effect: random SQL

The most dangerous part of these anti-patterns is that they reinforce each other perfectly. Separately, they may look tolerable. Together, they turn the system into a set of “hidden doors,” behind which SQL sits and waits for you to accidentally pull the handle.

Imagine a perfectly realistic scenario (and in Commerce Persistence Lab it is easy to reproduce). There is a thin controller that “just returns an order”:

package com.example.commerce.orders.web;

import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @GetMapping("/{id}")
    public PurchaseOrder get(@PathVariable Long id) {
        // Dangerous moment: the controller returns an entity to the outside.
        // Serialization/logging can then walk the graph and suddenly trigger SQL.
        return orderReadService.loadOrder(id);
    }
}

The service returns an entity (leakage), OSIV is enabled (the session lives until serialization), some relationships are EAGER (something is loaded “guaranteed”), the rest are LAZY (loaded “when needed”). And then, when the JSON serializer starts traversing the object, it may walk the graph like this:

graph TD
  O[PurchaseOrder] --> C[Customer]
  O --> I[items]
  I --> P[Product]
  P --> D[ProductDetails]
  P --> A[assignments]
  A --> K[Category]

If at that moment you think, “So what, I need all of that anyway,” that is already an important signal. Because maybe you do need all of that in one specific scenario (an order card for an admin panel). But definitely not “always and everywhere.” And the combo effect is designed so that “always and everywhere” appears easily and unnoticed.

The most insidious detail: the place where queries fire may not even be “in the business logic code.” It can be logging:

log.info("Order={} items={}", order.getOrderNumber(), order.getItems().size());

It can be toString() (if someone wrote it “conveniently”). It can be response serialization. And that is where OSIV does “good”: it prevents a crash. But at the same time it does “bad”: it allows these places to execute SQL.

And you get a very characteristic picture in the logs: the use case “seems to have read the order,” and then a fireworks display of additional selects begins. And the problem is no longer local “lazy here,” but systemic: you lost control over where and why data is loaded.

That is why it is not enough to ask, “Do we have a lazy relationship?” A proper audit question sounds harsher: at which point in the request lifecycle is SQL allowed to execute — inside the use case, or already in the controller, logger, and serializer. And if queries start happening in JSON, logs, or external code, then you have almost certainly already triggered the combination of OSIV + entity leakage, and EAGER has only made the picture heavier.

7. Typical mistakes with EAGER and OSIV

There is one unpleasant feature with these three anti-patterns: they are easy to justify as “practical.” That is why mistakes are repeated even by experienced people, just in a prettier wrapper. Below are several formulations that are useful to recognize “by ear,” because they often come up in reviews and discussions.

Mistake #1: “Let’s make everything EAGER so there are no lazy problems.”
This approach treats the symptom (sometimes) and almost always creates a new one: overfetching, N+1 through secondary selects, bloated joins, and unpredictable query costs for lists. It also usually cements a “universal graph” that is later carried into exports, lists, background jobs, and everyone wonders why everything got heavier.

Mistake #2: “Let’s enable OSIV — and LazyInitializationException will go away.”
Yes, it will. Along with it, the honest responsibility boundary will also disappear. SQL will start happening where you did not design it: in the controller, serialization, logs, mappers. On small datasets this looks like a win. Under real load it usually turns into “why is the same endpoint sometimes fast and sometimes slow.”

Mistake #3: saveAndFlush() is like Ctrl+S, let us use it for safety.
saveAndFlush() is not “save the file,” but a change in flush timing, and therefore a change in SQL submission timing. In a managed flow, it is almost always unnecessary, and sometimes harmful: it makes SQL earlier and can trigger a cascade of unexpected flush-before-query behavior. If you want “reliability,” tests and a clear transaction boundary provide it, not an extra flush.

Mistake #4: getReferenceById() is faster, let us always use it.
getReferenceById() is not “faster,” it is “different”: it is a proxy instead of data. It is useful when you really want a reference by id (for example, to set an FK without reading the row). But as a universal replacement for findById(), it usually means you are simply moving the SQL moment somewhere else and making diagnosis harder, especially if OSIV hides the consequences.

Mistake #5: “The repository is the persistence layer.”
A repository is an API. The persistence layer is decisions: what to read (entity or projection), how to read (fetch plan), where to read (transaction boundary), how to write (dirty checking vs merge vs bulk), and how to verify it (SQL trace, regression tests). If, instead of decisions, you use only universal CRUD methods “in hopes,” sooner or later you will get code that cannot be explained in review without the phrase “well, Hibernate decided so.”

1
Zadanie
Hibernate deep-dive, poziom 29, lekcja 2
Niedostępne
DTO-odczyt przy `open-in-view=false`
DTO-odczyt przy `open-in-view=false`
1
Zadanie
Hibernate deep-dive, poziom 29, lekcja 2
Niedostępne
Zmiana nazwy katalogu bez `saveAndFlush()`
Zmiana nazwy katalogu bez `saveAndFlush()`
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION