CodeGym /Kursy /Hibernate deep-dive /Refactoring patterns: split, queries, UoW

Refactoring patterns: split, queries, UoW

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

1. Refactoring patterns: small steps

If you have ever tried to “clean up the persistence layer,” you know the classic scenario: you open a service, see 300 lines, 12 repository calls, and somewhere between them saveAndFlush(), and then you think: “Well... let’s start by changing LAZY to EAGER; maybe that will help.” That is a normal human reaction, but it rarely leads to good code. We need refactoring patterns precisely so the brain stops darting around.

A refactoring pattern in the context of Hibernate is a small, repeatable improvement that makes ORM behavior more predictable. Not “speed everything up by 10 times,” not “remove all bugs forever,” but specifically: reduce the area of uncertainty. Hibernate is already “smart” (sometimes too smart), so our main task is to make sure it is smart within clear boundaries.

The three patterns here are useful precisely because they treat three typical problems very well.

Read-model split treats entity leakage: you stop returning entities outward when you just need to show a list or produce an export. The entity remains the write model, and reading gets its own data shape.

An explicit query (explicit query) treats the “magic repository” problem: instead of hoping that findById() will somehow “load everything correctly” on its own, you directly fix the read shape and fetch plan for the use case.

A narrower unit of work treats overscoped transactions: the transaction stops including “everything at once,” and Hibernate stops living too long and issuing SQL at unexpected moments.

So this will not stay in your head as three slogans, but as a working tool, let us fix a simple table — not as a “do this” checklist, but as a map of “what we treat with what.”

Symptom in the code What usually shows up in production Which refactoring move usually helps Typical example in Commerce Persistence Lab
PurchaseOrder unpredictable lazy loading, LazyInitializationException, or “why so much SQL?” read-model split order list / order summary
findById() and then it “walks the graph” N+1, secondary selects, strange price of “just reading” findForEditingById(), findSummaryById()
One method is transactional and does everything: reads, changes, formats, logs long transactions, extra flushes, locks, heavy diagnostics smaller unit of work closing orders + report generation

First it is useful to look at each move separately, and then at how they come together into one “before/after” scenario, so it is clear this is not theory, but quite concrete pieces of code.

2. Read-model split: reading vs writing

When you start writing a Spring Data JPA project, it is very tempting to do this: “everything will be an entity, and DTOs can come later.” At some point that even works. But closer to reality a nasty property emerges: an entity is a “living” thing. It can be managed, it can be a proxy, it can lazily load associations, and it can participate in dirty checking. If you return an entity to a place where it should not live, you are literally letting the cat out onto the street: it may come back, maybe not, but it will definitely bring surprises.

The essence of read-model split is simple: the entity is the write model, meaning the data shape that is convenient to change inside a transaction. And a read use case (a list, a summary card, an export, a report) should receive its own shape, usually as a DTO/projection. This is not “DDD religion,” but practical sanitation: you do not drag a managed graph into a place where all you need is a set of columns.

In our Commerce Persistence Lab, a typical example is the product list for the admin UI. For the list we usually need id, sku, name, possibly status and price. But ProductDetails, category assignments, audit, and the rest are for other use cases. So we make a narrow read model.

Example: a simple DTO for the product list

Let us start with a record (in Java 25 this is a very convenient “data without unnecessary philosophy” form):

package com.example.commerce.catalog.dto;

// DTO for the list: only what is needed for tabular output, without the entity graph and lazy relations.
public record ProductListRow(Long id, String sku, String name) {
}

Notice: this is not a “universal DTO for all cases in life.” It is intentionally boring and short. If a DTO starts growing into “everything about the product, including the author’s thoughts,” it turns into a giant DTO and repeats the fate of a giant entity graph.

Example: a query repository that returns a DTO

Next we create a query-oriented repository. Its job is not “everything CRUD,” but exactly one read scenario: get the rows for the list.

package com.example.commerce.catalog.query;

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

import java.util.List;

// Read repository: we return DTOs and thus fix the data shape.
public interface ProductQueryRepository extends Repository<Product, Long> {

    // The query immediately assembles "rows" for the UI: Hibernate cannot accidentally pull in relations through getters.
    @Query("""
        select new com.example.commerce.catalog.dto.ProductListRow(p.id, p.sku, p.name)
        from Product p
        where p.status = 'ACTIVE'
        order by p.name
    """)
    List<ProductListRow> findActiveRows();
}

There are several important things here that help specifically with Hibernate thinking.

First, the query fixes the data shape. Hibernate cannot “accidentally” load ProductDetails and collections, because we are not loading an entity — we are building the DTO directly.

Second, this is almost always cheaper: fewer columns in SQL, less data over the wire between the DB and the application, fewer objects in memory, less overhead on the persistence context.

Third, a lot of random side effects disappear. A DTO is not a lazy proxy, a DTO is not “managed.” It is just data.

Example: a read service that does NOT return an entity

Now let us build the read service. The key point here is not to “wrap the repository in a service for architectural box-checking,” but to define the boundary: a read service returns a read model.

package com.example.commerce.catalog.service;

import com.example.commerce.catalog.dto.ProductListRow;
import com.example.commerce.catalog.query.ProductQueryRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
public class ProductReadService {

    private final ProductQueryRepository productQueryRepository;

    public ProductReadService(ProductQueryRepository productQueryRepository) {
        this.productQueryRepository = productQueryRepository;
    }

    @Transactional(readOnly = true) // We mark this explicitly: it is read-only, with no intention to change or flush anything.
    public List<ProductListRow> listActiveProducts() {
        return productQueryRepository.findActiveRows();
    }
}

@Transactional(readOnly = true) is not here “because it looks nicer.” We already discussed that in Hibernate read-only mode affects flush expectations and can reduce extra dirty checking overhead. But the main point is not optimization; it is semantics: we clearly say “this is reading,” and do not turn the method into a hidden write scenario.

An important nuance: a projection can still reveal a wider join than it seems

It is tempting to write something like ProductListRow(Long id, String sku, String categoryName) and “just get the category name.” But as soon as the DTO contains fields from related entities, you have started choosing the query fetch shape. That is fine, just do it consciously.

If you add c.name, you will need a join, and the SQL will get wider. That is neither good nor bad — it is a decision. The problem starts when a developer thinks “I returned a DTO, so everything is safe,” while in fact they made a query with a large join that pulls more rows and may bloat the result. Read-model split does not eliminate the need to think about the query shape — it just makes that shape more explicit.

Mini diagram: what changes with read-model split

flowchart TD
    A["Service"] -->|returns entity| B["External code"]
    B -->|accidentally touches lazy| C["SQL surprises"]
    A2["Read Service"] -->|returns DTO| B2["External code"]
    B2 -->|DTO cannot be lazily loaded| D["Predictable behavior"]

If you need to remember one idea from this section, let it be this: an entity is not a “data format,” but “an object under ORM control”. So returning an entity outward for ordinary read scenarios is almost always suspicious.

Giant graph: not every case is solved by the fetch plan alone

It is useful here to separate two similar pains. Sometimes a giant graph appears because a read case is being served by an entity graph: a list or summary simply does not need the whole PurchaseOrder, and then read-model split and an explicit query help. But there is another story: the root itself ended up too broad and drags along collections, cascades, and lifecycle rules that do not have to live together.

Aggregate boundary here is simply the boundary of what really must change atomically together in one unit of work. If an order in almost every write case drags half the project with it, the problem is no longer only the fetch plan. You may have accidentally made one huge lifecycle chunk where narrower boundaries would be better.

What you see What it usually means The first move
giant graph is mostly needed for list reading, details, export read overfetch, the data shape did not match the use case read-model split + explicit query / fetch-plan
giant graph appears in almost every change because the root drags unrelated collections and cascades too broad aggregate / lifecycle boundary reconsider what really should live and change in one unit of work

This idea does not cancel DTOs and explicit queries. It just prevents you from treating every problem with the same fetch trick: sometimes you need to narrow the read, and sometimes you need to honestly admit that the write model took on too much.

3. Explicit queries: repository by use case

There is a stage in a project’s maturity where findById() and findAll() stop being convenient and start being dangerous. Not because they are bad, but because they are too universal. A universal method cannot know your use case. And Hibernate cannot read the developer’s mind (though sometimes it feels like it is trying, but it works... as it works).

An explicit query is when a repository/query method is named and implemented for a specific scenario, and in that same place important decisions are fixed: the selection shape, the necessary joins, the necessary fetch plan, sometimes a lock mode or sorting. In other words: “I read/change this, and like this,” not “well, I will get an entity and then we will see.”

In Commerce Persistence Lab a very typical example is loading an order for editing. If you do orderRepository.findById(id) and then in the service access order.getItems(), you are back to “fetch is determined by whoever called the getter.” That is a road to surprises.

Example: a repository method “for editing” with an explicit fetch plan

We can make a method with @EntityGraph (or JPQL join fetch, if that is easier for you to read). The point is not which tool you use, but that the method exists for the use case.

package com.example.commerce.orders.repository;

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

import java.util.Optional;

public interface OrderRepository extends JpaRepository<PurchaseOrder, Long> {

    // For the "editing" scenario we fix that customer and items are needed (not "maybe a getter will be called later").
    @EntityGraph(attributePaths = {"customer", "items"})
    Optional<PurchaseOrder> findForEditingById(Long id);
}

What does such a method give us?

It makes the data loading boundary explicit. If the “editing” use case requires the customer and line items, that is visible in the repository code, not hidden in “somewhere a getter was called.”

It reduces the risk of N+1. We already know that EAGER does not always produce one query, and LAZY can turn into secondary selects. Here you fix: “in this scenario I want to load this.”

It improves code review. The method findForEditingById() itself makes you ask: “why do editing needs require items?” — and that is a good question. With findById() that question usually does not arise, because “well, it is just find.”

Example: a query repository for “summary” instead of entity

Sometimes you do not need the whole order, only a summary: number and customer email. This is a classic read-model split, but I will include it here as an example of an explicit query: the method explicitly expresses the data shape and join.

package com.example.commerce.orders.query;

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

public interface OrderQueryRepository extends Repository<PurchaseOrder, Long> {

    // Important: we return not an entity, but a DTO summary so reading is predictable and "narrow".
    @Query("""
        select new com.example.commerce.orders.dto.OrderSummary(o.id, o.orderNumber, c.email)
        from PurchaseOrder o
        join o.customer c
        where o.id = :id
    """)
    OrderSummary findSummaryById(@Param("id") Long id);
}

And the DTO itself:

package com.example.commerce.orders.dto;

// Order summary for reading: minimum data so the PurchaseOrder graph is not dragged outward.
public record OrderSummary(Long id, String orderNumber, String customerEmail) {
}

What matters here is that the method is not called “findSomething,” but reflects a concrete read use case. In mature code this looks boring, but it works like road markings: you make fewer mistakes because you do less “internal negotiation” about behavior in your head.

“Explicit query” is also about intent

A very common anti-pattern in the persistence layer is when code looks like reading but does writing, or vice versa. For example, a method returns List<PurchaseOrder>, but along the way changes a status or touches relations, and you get a flush at an unexpected moment. An explicit query helps separate reading from writing, because read methods more easily maintain discipline: read-only transaction, DTO, fixed data shape.

A small diagram: “universal” vs “explicit”

flowchart TD
    S["Service"] --> R1["findById"]
    R1 -->|entity| S
    S -->|later somewhere| L["Lazy access"]
    L --> Q["SQL surprises"]

    S2["Service"] --> R2["findForEditingById"]
    R2 -->|entity with planned fetch| S2
    S2 --> OK["Predictable SQL boundary"]

Honestly, the biggest plus of explicit queries is that they remove the need to “remember everything”. You stop being a human cache for which relations are needed where. The code says it for you.

4. Smaller unit of work: narrow the transaction

A long transaction in the Hibernate world is rarely just a “long transaction.” Usually it is also a large persistence context, more dirty checking work, a higher chance of getting a flush “at the wrong moment,” a higher chance of holding a lock too long, and a higher risk of accidentally issuing extra SQL because someone inside the transaction “just” called size() on a collection. In the end you get code that is technically correct, but in behavior it resembles a room with a cat and a bag: quiet, but that does not mean safe.

The smaller unit of work pattern means the transaction should cover only one connected business operation, not “an operation plus a report, plus logging, plus response formatting.” The most practical way to learn this is to look for places where work that does not require a transaction happens inside @Transactional: building strings, sorting, grouping, serialization, preparing a “pretty response.”

In Commerce Persistence Lab a good example is order closing and report generation. Naively it looks like this: we open a transaction, loop through a list of IDs, change the status, and immediately build a text report. Formally everything works. But the transaction lives longer than necessary, and the persistence context keeps managed entities while you format text. If you touch something lazy in the process, you will get extra queries. If the list is large, you will get extra dirty checking load.

The correct refactoring: the transaction does only the mutation and returns the minimum result that is safe outside the transaction. And report formatting lives outside, where Hibernate is already “not involved.”

Example: a command service closes orders and returns numbers

package com.example.commerce.orders.service;

import com.example.commerce.orders.entity.OrderStatus;
import com.example.commerce.orders.entity.PurchaseOrder;
import com.example.commerce.orders.repository.OrderRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
public class OrderCloseCommandService {

    private final OrderRepository orderRepository;

    public OrderCloseCommandService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional // Only mutation lives here: we change the status and exit the transaction immediately.
    public List<String> closeOrders(List<Long> ids) {
        return orderRepository.findAllById(ids).stream()
            .peek(o -> o.setStatus(OrderStatus.CLOSED)) // The side effect is intentional: we update a managed entity inside the UoW.
            .map(PurchaseOrder::getOrderNumber) // We return a simple value that is safe outside the transaction.
            .toList();
    }
}

The point is that we return simple values that already exist in PurchaseOrder and do not require lazy access. We do not return List<PurchaseOrder> outward, because that would reopen the door to entity leakage and accidental lazy loading.

Example: a separate service builds the report outside the transaction

package com.example.commerce.orders.service;

import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class OrderCloseReportService {

    private final OrderCloseCommandService commandService;

    public OrderCloseReportService(OrderCloseCommandService commandService) {
        this.commandService = commandService;
    }

    public String closeAndBuildReport(List<Long> ids) {
        List<String> numbers = commandService.closeOrders(ids); // The transaction ends inside commandService.
        return String.join("\n", numbers); // Formatting happens outside: Hibernate is already "not involved" here.
    }
}

That is what smaller unit of work means in the most applied sense: the transaction closes immediately after the mutation part, and everything else happens without Hibernate.

A nuance people often trip over: self-invocation

Sometimes a developer tries to make “one class, but two methods”: an outer non-transactional method calls an inner @Transactional method. If the call happens inside the same class, the transaction may not apply because of Spring’s proxy mechanism. We already discussed this earlier, so here I will just remind you: if you really want to separate boundaries, make a separate bean (as in the example above) or use other explicit mechanisms. Do not create a “Schrödinger’s transaction” that exists only in the annotation.

Mini diagram: “wide” vs “narrow” unit of work

flowchart TD
    A["@Transactional method"] --> B["Reading"]
    B --> C["Changing"]
    C --> D["Formatting/logging/export"]
    D --> E["Commit"]

    A2["@Transactional command"] --> C2["Changing"]
    C2 --> E2["Commit"]
    E2 --> D2["Formatting outside the transaction"]

If you look at this as a rule, it sounds a little boring, but in a real project it saves hours of diagnostics: inside the transaction, keep only what really requires a transaction.

5. The “before/after” scenario in Commerce Persistence Lab

When refactoring patterns live separately, they look like three independent ideas. In practice they work well together, because each one closes the weak spot of the others. Let us assemble a small story on the familiar order domain without turning it into “let’s rewrite the whole application.”

Imagine a use case: “show the administrator a table of orders and allow closing the selected ones.” In the naive version, you could make one service: it loads a list of PurchaseOrder, returns entities outward, the UI reads customer.email somewhere, then the user selects several orders, the service closes them, and returns a list of closed entities.

The problem is that this version relies on three “maybe it will work”: maybe nobody touches extra lazy relations, maybe the transaction is not broader than we think, and maybe findById() in a loop will not create a scale problem.

In the refactored version we do it boringly, but safely.

The order list is returned as a DTO (that is read-model split). The repository for the list is query-oriented, and the query explicitly fixes the join to the customer only for the email (that is an explicit query). The read service is marked with @Transactional(readOnly = true) and returns a list of rows, not entities.

Closing orders is done by a separate command service that changes the status in a transaction and returns only the numbers of the closed orders (that is smaller unit of work + avoiding entity leakage on the way out). If you need to show the result, a separate layer outside the transaction assembles a report string or a DTO for the response.

The important thing is that this does not require an “architectural revolution”. We are not introducing new technologies, not changing Hibernate, and not rewriting the whole domain model. We are simply making data boundaries explicit: what we read, what we write, where the transaction starts, and where it ends.

And this almost always gives two engineering effects that are pleasant even for beginners.

The first effect is that SQL becomes readable. Not “I think there are fewer queries,” but actually fewer places where a query can appear “by itself.” You read a DTO — so extra SQL will not appear because of a getter.

The second effect is that code becomes easier to review. When you see findForEditingById() or findActiveRows(), you understand the intention. When you see the service OrderCloseCommandService, you understand the mutation boundary. This is a very “non-magical” style, and it works beautifully together with the fact that Hibernate is magical by nature. We are balancing forces: Hibernate does the implicit work inside the unit of work, and we make the unit of work itself and the data shape explicit.

6. Typical mistakes when refactoring the persistence layer

Mistake #1: replacing a giant entity graph with a giant DTO and assuming things improved.
Sometimes a developer hears “read-model split” and decides: “Oh, then let us make one DTO OrderFullDto with 40 fields and live happily.” The problem is that you have simply moved gigantism from the entity to the DTO. Such a DTO will start pulling joins, bloating the result set, complicating queries, and become a “universal format for everything.” It is better to make several narrow read models: one for the list, one for the detail page, one for export. Boring, but stable.

Mistake #2: make a DTO, but keep a service that returns an entity “just in case.”
It often happens like this: someone added OrderSummary, but the old getOrder() method still returns PurchaseOrder, because “well, sometimes someone needs it.” As a result, entity leakage remains, and the DTO does not become the standard path. It is important to agree on a project rule: read services return read models, and entities stay inside the persistence/write layer. If “sometimes someone needs it,” that usually means “we have two different use cases that we accidentally mixed.”

Mistake #3: trying to narrow the unit of work, but leaving formatting, sorting, and “pretty” mappings inside the transaction.
Narrowing the transaction is not about moving the @Transactional annotation to another method and calming down. It is about moving everything out of the transaction that does not require a transaction. If inside @Transactional you build JSON, CSV, huge strings, or do complex grouping, the transaction is still broad. Hibernate still lives for a long time. It is better to return minimal data from the transaction (IDs, numbers, amounts) and build the “beauty” outside.

Mistake #4: writing explicit queries but not reflecting the use case in the method name.
You can make an @Query and name the method findSomethingCool(). Formally this is an explicit query, but it still feels like magic. A good method name is part of predictability. findForEditingById(), findActiveRows(), findSummaryById() sound a bit verbose, but they explain the intent and help you avoid using the method for the wrong purpose.

Mistake #5: fixing only the code and ignoring the SQL trace and tests.
The persistence layer is deceptive: after refactoring, tests may still be green, while the SQL behavior is still problematic (for example, there are fewer queries, but a wider join, or the N+1 is gone but an accidental update appeared). So after each refactoring move, it is important to at least look at the SQL trace once: how many queries there are, what the shape is, and whether there are any extra UPDATEs. We are not doing a performance audit on every commit, but “looking with our own eyes” is basic hygiene.

1
Zadanie
Hibernate deep-dive, poziom 29, lekcja 3
Niedostępne
Lista aktywnych produktow jako read-model
Lista aktywnych produktow jako read-model
1
Zadanie
Hibernate deep-dive, poziom 29, lekcja 3
Niedostępne
Jawne żądanie dla scenariusza zatwierdzania wniosku
Jawne żądanie dla scenariusza zatwierdzania wniosku
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION