CodeGym /Courses /Hibernate deep-dive /Selection matrix: loading and bulk operations

Selection matrix: loading and bulk operations

Hibernate deep-dive
Level 30, Lesson 1
Available

1. Selection matrix: the point

Honestly, the most typical problem a developer faces in Hibernate-heavy code is not that they “do not know the annotations.” The problem is that they know too many buttons, and they start choosing by habit: “entity everywhere,” “JOIN FETCH everywhere,” “save() everywhere,” “native SQL everywhere” — strike out what is unnecessary, commit what is left, and pray. A selection matrix is a way to regain calm: you do not guess; you ask a few right questions and get a logical answer.

The audit dimensions already show where it hurts. But a finding is useless if the treatment is then chosen by reflex: we saw a lot of SQL — we added JOIN FETCH; we saw detached data — we shoved in merge(); we saw a bulk update — we wrapped save() in a loop. We need a matrix that first separates read and write, and only then narrows down the tool.

Imagine you came into a kitchen. You need to chop a salad. On the table there are: a chainsaw, an axe, a scalpel, scissors, and a normal kitchen knife. Formally, you can make a salad with almost anything on that list. But there is a feeling that some tools are not meant for everyday work. It is the same in Hibernate: StatelessSession or bulk update is not a “bad” tool, it is just not for every scenario. And vice versa, entity loading is not “evil,” it is simply expensive where a lightweight read snapshot is enough.

Within Commerce Persistence Lab, this is especially visible: we have a catalog, orders, inventory, and audit. Some scenarios look like “a grid in an admin panel,” others like “a detailed order card,” and others like “a mass repricing of the catalog.” The mistake begins exactly when we take one style and try to solve everything with it, as if Hibernate were a Swiss Army knife rather than a set of different tools.

2. Read vs write: the fork in the road

The most useful “primary fork” is not JOIN FETCH vs EntityGraph, but something much simpler: are we reading or writing right now? It sounds trivial, but this is exactly the stage where expensive mistakes most often appear, such as “I loaded an entity for a list and accidentally dragged along a managed graph,” or “I wanted to bulk-update 50k rows, but I did a loop over entities.”

Below is a simplified diagram that is enough to keep your brain from panicking and start thinking like an engineer rather than a shaman with a drum (you can use the drum, but later — after work).

flowchart TD
    A["Use case"] --> B{"Read or Write?"}

    B -->|Read| C{"List / Details / Report?"}
    C -->|List| D["Projection (DTO / interface)"]
    C -->|Details| E["Entity loading + fetch-plan"]
    C -->|Report| F["Projection or native SQL"]

    B -->|Write| G{"One aggregate or many rows?"}
    G -->|One aggregate| H["find + mutate (managed)"]
    G -->|Many rows| I["bulk update/delete or StatelessSession"]

Notice the moral of the diagram: tools do not “fight” each other; they occupy different cells. Projection does not compete with optimistic locking because these are different scenarios. Bulk update does not compete with EntityGraph because one is about mass writes and the other is about a read shape.

Quick test: do you need a managed entity?

There is a simple internal question that almost always helps: “Am I going to change the state of this object in this operation?” If the answer is “no, I am just showing a list/report/dashboard,” then you most likely do not need a managed entity. And if you do not need a managed entity, then many related problems (dirty checking, accidental UPDATE, graphs, lazy traps) do not even get invited to the party.

If the answer is “yes, I am changing an order status, renaming a product, reserving stock,” then a managed entity becomes almost inevitable: it gives you a proper unit of work model, versioning, correct flush, cascades (if they are appropriate), and the ORM comfort that you started this whole thing for.

In Commerce Persistence Lab, it looks like this: a product list for the admin panel is usually a read scenario, a product card is a read scenario but a detailed one, and changing the product price or reserving stock are write scenarios where a managed entity is perfectly appropriate.

So the matrix is not needed for a pretty wall chart. These same forks later show up in ordinary code: entity where a list-row DTO is needed; saveAndFlush() where a managed update would have been enough; bulk where people keep thinking in managed objects.

3. Read scenarios: list, card, report

People often think about reads in ORM like this: “well, it is just findAll() and then .map(...).” And this is where Hibernate quietly starts crying in the corner, because “just” turns into N+1, into excessive graphs, into accidental collection initialization, and into reading the whole world just to get two columns. That is why, in the selection matrix, it is important to distinguish the shape of the result right away: list, details, or report.

A list is usually a tabular format: many rows, but few columns, and almost never the full graph. A card is usually “one entity + several related data points,” and the relationships must be selected deliberately. A report is often aggregates, GROUP BY, sums, statistics, where the ORM language sometimes starts to “resist,” and it is more honest to switch to native SQL or at least to a specialized projection.

List/table: projection as the default candidate

For lists, especially backoffice tables, projection is almost the ideal default. It does not drag along a managed graph, does not create extra dirty checking, does not accidentally provoke lazy navigation, and, importantly, makes the read contract explicit: these are exactly the fields we return, and nothing will be pulled in “automatically,” even if somewhere there is @ManyToOne(fetch = EAGER) (which we, of course, do not like).

Let us start with a simple DTO using a record — it is a very friendly format for a list.

import com.example.commerce.catalog.entity.ProductStatus;

public record ProductListRow(
        Long id,
        String sku,
        String name,
        ProductStatus status
) {}

Now a repository that returns “table rows” rather than entities:

import com.example.commerce.catalog.dto.ProductListRow;
import com.example.commerce.catalog.entity.Product;
import com.example.commerce.catalog.entity.ProductStatus;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;

import java.util.List;

// Read repository: here we return a projection, not a managed entity
public interface ProductQueryRepository extends Repository<Product, Long> {

    // JPQL constructor: form the DTO directly in the query so we do not drag in the entity graph
    @Query("""
            select new com.example.commerce.catalog.dto.ProductListRow(p.id, p.sku, p.name, p.status)
            from Product p
            where p.status = :status
            """)
    List<ProductListRow> findListRows(ProductStatus status);
}

Notice a small but important piece of architectural pedantry: we do not have to mix a write repository (JpaRepository<Product, Long>) and read queries in one interface. In the project, we have already been moving toward separating ...repository and ...query. The selection matrix fits perfectly into such a structure: the list belongs to the query layer, not to an entity operation.

Card/details: entity loading + scenario-specific fetch-plan

A product card or detailed order data are scenarios where entity loading is justified. Not because “it is easier,” but because you may need navigation across relationships, domain model logic, lazy fields on demand, and sometimes further mutation in the same transaction (for example, “opened the order and immediately changed the status” — not the best UX, but it works as a training example).

The key point: when you choose entity loading for a detail use case, you automatically choose one more thing — fetch plan. That is, you decide which associations should be loaded within this scenario. Not “forever in annotations,” but specifically for the use case.

The simplest option: EntityGraph for a detailed order.

import com.example.commerce.orders.entity.PurchaseOrder;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.repository.Repository;

import java.util.Optional;

public interface OrderDetailsRepository extends Repository<PurchaseOrder, Long> {

    // IMPORTANT: this is a scenario-specific fetch-plan — for this method we guarantee customer and items
    @EntityGraph(attributePaths = {"customer", "items"})
    Optional<PurchaseOrder> findDetailedById(Long id);
}

Such a method does not promise that “items will always be available everywhere for an order”; it promises something concrete: in this scenario, we load customer and items. It is like ordering a pizza “with cheese and mushrooms,” rather than buying a whole refrigerator of pizza “just in case.”

Reporting-style reads: native SQL as an honest boundary

Report queries often require aggregates and groupings, and sometimes fairly “flat” results. You can force JPQL to express that, you can build Criteria, but sometimes it is simpler and more honest to write SQL. That is normal. ORM is a tool, not a religion.

For example: “how many units of each SKU were sold” (a hypothetical sales summary). Let us make an interface projection so we do not have to deal with Object[].

public interface SalesSummaryRow {
    String getSku();
    long getTotalQty();
}

And a native query in the repository:

import com.example.commerce.orders.entity.OrderItem;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;

import java.util.List;

public interface SalesReportRepository extends Repository<OrderItem, Long> {

    // Native SQL is appropriate for aggregates: flat result, GROUP BY, sum(...)
    // Return an interface projection so we do not work with Object[]
    @Query(value = """
            select p.sku as sku, sum(oi.quantity) as totalQty
            from order_item oi
            join product p on p.id = oi.product_id
            group by p.sku
            """, nativeQuery = true)
    List<SalesSummaryRow> findSalesSummary();
}

What matters here is not the SQL itself, but its place in the matrix: a reporting read case is a candidate for native SQL because it is flat, aggregate, and does not require managed entities. This helps avoid dragging ORM mechanics into a place where they provide no benefit.

4. Fetch-plan toolbox

When we decide that “yes, an entity is needed,” the selection matrix switches to the next level: how do we load the required relationships without N+1 and without overfetching? It is very easy to fall into an extreme here: either “everything EAGER,” or “everything JOIN FETCH,” or “I will not load anything at all; lazy will figure it out.” The mature answer is usually in the middle: you choose the tool according to the scenario shape.

There are three typical tools that cover 80% of tasks most often: JOIN FETCH, EntityGraph, and batch fetching. We already discussed them earlier, but now we need a decision-oriented understanding: when to take which one in the matrix.

JOIN FETCH: fast, powerful, but sometimes it “inflates” the result

JOIN FETCH is like a vacuum cleaner: it really can remove N+1, but if you turn it to maximum power in a small room, it will start sucking not only dust, but also the carpet, the cat, and your self-esteem. The problem with JOIN FETCH is not that it is bad; it is that it changes the shape of the result and can inflate the result set, especially on collections.

For detailed reading of a single order with items, JOIN FETCH is often appropriate:

import com.example.commerce.orders.entity.PurchaseOrder;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;

import java.util.Optional;

public interface OrderFetchJoinRepository extends Repository<PurchaseOrder, Long> {

    // JOIN FETCH removes N+1 at the cost of "inflating" the result: be especially careful with collections
    @Query("""
            select po
            from PurchaseOrder po
            join fetch po.items
            where po.id = :id
            """)
    Optional<PurchaseOrder> findByIdWithItems(Long id);
}

In the matrix, this looks like: detail read, one root, one collection — JOIN FETCH is often a good candidate. But list read + paging + fetch join on collections is already a red flag (and we will leave the detailed limitations in your head as “check it,” without turning the lecture into a repeat of previous days).

EntityGraph: managed fetch-plan without rewriting queries

EntityGraph is convenient when you want “the same query” but with different association loading for different scenarios. It fits well into Spring Data, where read methods are already described and you add a scenario-specific fetch plan. And often it reads more simply than JPQL with several join fetches.

The example “detailed order: customer + line items + product for each line item” can be expressed with a graph:

import com.example.commerce.orders.entity.PurchaseOrder;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.repository.Repository;

import java.util.Optional;

public interface OrderGraphRepository extends Repository<PurchaseOrder, Long> {

    @EntityGraph(attributePaths = {"customer", "items", "items.product"})
    Optional<PurchaseOrder> findById(Long id);
}

In the matrix, this is a good candidate when you need an entity, but want to keep the query “normal” and manage loading as a scenario setting. It is like switching the headlights: the car is the same, but the lighting mode is different.

Batch fetching: “let there be several queries, but smart ones”

Batch fetching is a compromise: you accept that there will be several SQL queries, but you want them to be grouped, not N+1. This is especially appropriate when JOIN FETCH would produce one huge and heavy query (for example, duplicate root rows, wide result), and you are fine with a few secondary selects, just not one per row.

At the mapping level, this can look like this (an example for PurchaseOrder.items):

import org.hibernate.annotations.BatchSize;

import jakarta.persistence.OneToMany;
import jakarta.persistence.FetchType;

import java.util.ArrayList;
import java.util.List;

class PurchaseOrder {

    // BatchSize helps group lazy collection loading (not N+1, but "in batches")
    @BatchSize(size = 20)
    // Keep LAZY so we do not "explode" the graph all the time; load it deliberately by scenario
    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<OrderItem> items = new ArrayList<>();
}

In the matrix, this often looks like: “we read a list of orders, and sometimes we need to walk through items, but JOIN FETCH is too expensive.” Then batch fetching is a normal, controlled compromise: not magic, but a deliberate price.

5. Write: find + mutate

In write scenarios, the main trap is not fetch plans, but the update style. The most predictable and understandable pattern for “change one entity/aggregate” is find + mutate inside a transaction. It sounds boring, but boring solutions are often the most reliable (unlike the “beautiful universal merge of the whole graph at once”).

An example from the catalog: rename a product. No need for save() “just in case” if the entity is managed.

import com.example.commerce.catalog.entity.Product;
import com.example.commerce.catalog.repository.ProductRepository;
import org.springframework.transaction.annotation.Transactional;

@Transactional // The transaction is needed so the entity becomes managed and dirty checking works
public class ProductService {

    private final ProductRepository productRepository;

    public ProductService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    public void renameProduct(long productId, String newName) {
        // Load a managed entity and change its state — Hibernate will issue UPDATE on flush/commit
        Product product = productRepository.findById(productId).orElseThrow();
        product.setName(newName);
    }
}

In the matrix, this is exactly the case: write use case, one aggregate, a clear transaction, Hibernate does dirty checking and sends UPDATE where it should. You do not make unnecessary moves and do not create extra SELECT/flush calls that you will later diagnose as if it were an ORM bug.

6. Bulk changes: bulk and synchronization

When many rows change under the same rule, the entity loop almost always loses. The reason is simple: the entity loop pays for object materialization, snapshots, dirty checking, and possibly cascades — all for an operation that is, in essence, “one SQL command for many rows.” Bulk update/delete is a way to say honestly: “I do not need the object world; I need to change a set of rows.”

This is exactly where the performance gain immediately raises a correctness question: what is now true for already loaded managed objects?

Example: change the status of all catalog products in bulk (a hypothetical admin operation).

import com.example.commerce.catalog.entity.Product;
import com.example.commerce.catalog.entity.ProductStatus;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;

public interface ProductBulkRepository extends Repository<Product, Long> {

    // Bulk goes directly to the database and does not synchronize already loaded managed entities
    @Modifying
    @Query("""
            update Product p
            set p.status = :status
            where p.deleted = false
            """)
    int changeStatusForCatalog(ProductStatus status);
}

The key meaning for the matrix: bulk is not an “accelerator for everything,” it is a tool for the scenario “many rows, one rule.” And then the next matrix question immediately kicks in: “what about the persistence context?” because bulk bypasses the managed model.

Synchronization after bulk

After a bulk operation, it is important not to keep living as if nothing happened. Bulk changes the database directly, while already loaded managed objects keep their old state. The canonical protocol here is: bulk changes rows, not managed objects. Therefore, after it, either clear the context and read again, or keep bulk in a separate unit of work. clearAutomatically and flushAutomatically can be convenient as a convenience option, but first it is better to keep this cause-and-effect link in mind.

And one more practical point: if before bulk in the same transaction you already have accumulated changes to managed entities, first solve the flush of those changes separately. Otherwise you are mixing two working models and then wondering about the order of SQL statements.

If you want to show this explicitly at the service level, you can do it like this:

import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;

@Transactional
public class CatalogAdminService {

    private final ProductBulkRepository bulkRepository;
    private final EntityManager entityManager;

    public CatalogAdminService(ProductBulkRepository bulkRepository, EntityManager entityManager) {
        this.bulkRepository = bulkRepository;
        this.entityManager = entityManager;
    }

    public int hideAllProducts() {
        int updated = bulkRepository.changeStatusForCatalog(ProductStatus.HIDDEN);

        // After bulk, old managed objects can no longer be treated as truth
        entityManager.clear();

        return updated;
    }
}

If, after this scenario, you need to look at the same products again, do a fresh read after clear(). Do not assume the old references magically updated.

StatelessSession: heavy scenarios

StatelessSession in Hibernate is like a truck: it is wonderful when you need to transport a lot, but strange to drive one to the store for bread. It disables familiar things (persistence context, first-level cache, automatic dirty checking) and thus becomes useful for special high-volume scenarios where the usual managed model is too expensive.

For example, a mass insertion of InventorySnapshot in laboratory performance scenarios (the idea is schematic, without infrastructure details):

import jakarta.persistence.EntityManager;
import org.hibernate.Session;
import org.hibernate.StatelessSession;

public class SnapshotImportService {

    private final EntityManager entityManager;

    public SnapshotImportService(EntityManager entityManager) {
        this.entityManager = entityManager;
    }

    public void insertSnapshot(Object snapshotEntity) {
        Session session = entityManager.unwrap(Session.class);

        try (StatelessSession ss = session.getSessionFactory().openStatelessSession()) {
            ss.insert(snapshotEntity);
        }
    }
}

In the matrix, this goes into the cell “very large volume, simple pipeline, managed magic is not needed.” And it is very important to remember the second half of that sentence: managed magic is not needed. If it is needed, StatelessSession turns from a truck into a problem.

7. Summary selection matrix

After the lecture, I want you to be left not with “a pile of knowledge,” but with a small support structure for your brain. Below is a compact cheat-sheet matrix. It does not replace thinking, but it helps you avoid extremes and quickly narrow the choice down to 1–2 candidates, which are then checked by SQL, statistics, and tests.

Use case in Commerce Persistence Lab First candidate Why this is usually right When to switch to another tool
Product list (admin table) Projection (DTO/record) Narrow result, no managed graph, lower risk of N+1 and accidental updates Need “details”/relationships directly in the list → EntityGraph/batch fetching; need an aggregate report → native SQL
Product card (Product + ProductDetails) Entity + fetch-plan This is a detail read where the entity model is convenient, but loading must be controlled If the data is strictly flat → projection; if it is a report → native SQL
Order details (PurchaseOrder + items) EntityGraph or JOIN FETCH You need a graph under one root; N+1 can be removed in a controlled way For lists with paging, do not fetch join collections; sometimes batch fetching is better
Change one product/order find + mutate Predictable unit of work, dirty checking, minimal surprises If a detached graph arrives and you want to “glue everything together at once” → carefully and deliberately, but more often find + mutate is still better
Bulk catalog change (repricing/status) bulk update/delete One SQL statement for many rows instead of an expensive entity loop If you need to continue working with entities in the same transaction after bulk, you must have a synchronization plan (clear/re-read)
Large import/cleanup of historical data StatelessSession / bulk Minimal overhead, volume control If you need managed logic, cascades, versioning, or complex relationships — return to the regular model + batching
Report: sales/aggregates native SQL + projection Honest query shape, transparency, no ORM graph drag If the query is simple and fits JPQL — you can stay in JPQL

If you want to “compress” the matrix into one pseudocode block, it looks roughly like this (and yes, this is intentionally a simple formula — you are not required to turn it into philosophy):

if read:
  if list → projection
  if details → entity + fetch-plan
  if report → projection or native SQL

if write:
  if one aggregate → find + mutate
  if many rows → bulk update/delete
  if very large volume pipeline → StatelessSession (narrowly)

8. Typical mistakes when choosing an approach

Mistakes in tool selection usually do not look like “the code does not compile,” but rather like “everything works, but for some reason it hurts.” It hurts in production, hurts under load, hurts in maintenance, and sometimes hurts the data (that is the worst). So it is better to remember a few typical “matrix failures” and recognize them in code in advance, like familiar characters in a series: “oh, it is you again.”

Mistake #1: returning entities in every read scenario “because that is what we are used to.”
Almost always this leads to the list becoming a mini-graph of entities, and then someone accidentally touches getCustomer() in a stream, and you get N+1. In the list matrix, the default is projection, and entity is a deliberate exception.

Mistake #2: treating everything with JOIN FETCH, even when the scenario is a paging list.
JOIN FETCH is excellent for detailed reads, but on lists with collections it can inflate the result and break expectations. The matrix specifically separates “detail read” and “list read”: for lists, projection or batch fetching is often simpler and cheaper.

Mistake #3: doing a bulk update and continuing to read/change already loaded managed objects as if nothing happened.
Bulk is an operation “outside” the persistence context. Therefore, after bulk, either the context is cleared or the scenario is built so there are no illusions that “I already read the product, so it updated itself.” The matrix does not forbid bulk; it requires discipline after choosing bulk.

Mistake #4: trying to use merge() as a universal “save everything that comes in.”
merge() can be useful, but as a universal style it almost always makes behavior expensive and unpredictable: extra SELECTs, unexpected side effects on the graph, cascades, duplicate children. For “ordinary change,” the matrix prefers find + mutate because it is a controlled scenario.

Mistake #5: using native SQL “just in case” so you do not have to think about ORM.
Native SQL is a normal tool, but it should appear where it provides a gain in clarity or expressiveness (aggregates, reports, complex join forms). If the task is a normal product list, native SQL often only complicates maintenance. The matrix first suggests projection/JPQL, and native SQL only as an honest boundary, not as the default.

1
Task
Hibernate deep-dive, level 30, lesson 1
Locked
Product List via Projection
Product List via Projection
1
Task
Hibernate deep-dive, level 30, lesson 1
Locked
Bulk status update with bulk update
Bulk status update with bulk update
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION