CodeGym /Courses /Hibernate deep-dive /@Transactional ( readOnly = true )

@Transactional ( readOnly = true )

Hibernate deep-dive
Level 17, Lesson 2
Available

1. Transactions as a read boundary in JPA/Hibernate

With write use cases, the picture is already clear: one service operation, one persistence context, and Hibernate calmly carries changes through flush/commit. With reads, the mirror question appears: if we are not writing anything, do we need a service transaction at all, and why, then, readOnly=true? Intuition says: “I am not changing anything, so a transaction is not needed.” That sounds logical, but Hibernate is not built like a “SQL console with SELECT”; it is a runtime system that manages the lifecycle of objects. Even reading often needs a boundary: for lazy loading, for read consistency, for predictable persistence context behavior, and so your code does not fall apart when OSIV (open-in-view=false) is turned off, which in our course is a basic discipline.

In a typical Spring Boot application, reading can work even without an explicit @Transactional on the service... but this usually means that the transaction will be created somewhere “inside” — for example, at the repository call level. Then each repository method becomes a small separate story with its own persistence context, its own session, and its own chances for lazy problems.

Imagine that you want to read an order and its items, and then also check stock levels for the products. If you call three repositories one after another without a shared service boundary, you will almost certainly get three independent mini-transactions. At that point, Hibernate stops being your “unit of work assistant” and becomes a “set of separate calls.” And if you try, after the first call, to walk through the lazy collection order.getItems(), then with open-in-view=false you meet a classic of the genre — LazyInitializationException. Not because Hibernate is mean, but because you are asking it to load data when the session is already closed.

At this point, a useful thought appears: reading is also a use case, and it is useful to formalize it just as strictly as writing. And that is where readOnly=true starts to play the role not of an “everything booster,” but of a marker of meaning: “we entered a read scenario, we are not changing anything, we are just fetching data and leaving.”

2. readOnly=true as a scenario contract

The readOnly=true flag in @Transactional is often used as a magical optimization: “I will set readOnly, and everything will become faster.” Sometimes it really does become a little easier (we will soon look at why), but that is a side effect. The main value of a read-only transaction is semantics and discipline: you are telling yourself (and your colleagues) that the method should not change system state. It is like a “DO NOT TOUCH, EXHIBIT” sticker in a museum: the sticker does not bolt the exhibit to the floor with steel screws, but it sharply improves the chances that nobody will start moving it around.

In our Commerce Persistence Lab project, read-only scenarios are, for example, “show the order list,” “read the order status,” “show the product card,” “return stock for a SKU,” “generate an order summary.” These are operations that should be predictable: they must not accidentally trigger UPDATE, must not mix changes into the persistence context, and must not silently turn into a write use case.

There is a useful engineering habit: as soon as a method is declared @Transactional(readOnly=true), you mentally read it as: “inside, we may read, have lazy access (while we are inside the boundary), build DTOs, but we do not have the right to change entities.” At the level of Java syntax, nobody forbids you from calling a setter — your hand can still slip. But that is exactly why a read-only transaction is an agreement that must be supported by coding style: separate query methods from command methods, and do not mix “looked at it” and “changed it” in one place.

To pin down expectations, it is convenient to keep a small table in mind, “what readOnly does and does not do”:

Question What usually happens with readOnly=true What not to expect
Code semantics The method reads like a read use case That it is “just for speed”
Flush / dirty checking Hibernate often does less unnecessary work That writing is “impossible” in principle
Java objects Remain ordinary mutable objects That the object becomes immutable
Error protection Helps keep reading from turning into writing That it replaces code review and discipline

What matters here is the meaning itself: readOnly is a truthful marker of the scenario and a useful signal to Hibernate. The annotation’s technical limitations themselves do not replace that meaning.

3. Read-only by default in Spring Data JPA

If you open the Spring Data JPA sources (not necessarily right now, this is just a fact of how it is built), you will see that the base repository implementation is organized roughly like this: the class is marked transactional “for reading,” while write methods are overridden as ordinary write transactions. So the feeling “I did not annotate anything, but it works” is not magic. Repository reads really do often execute in a read-only transaction.

But there is a subtle point here that causes confusion and bugs in real applications. Repository-level read-only transactionality is a transaction at the level of one call. That means if your service does:

  1. orderRepository.findById(...)
  2. inventoryRepository.findByProductId(...)
  3. customerRepository.findById(...)

then without a shared service boundary, these are three separate transactions. They are short, and for individual queries that is often enough, but you lose:

  • first, a single persistence context and first-level cache for the whole scenario, which means you may read the same rows from the database repeatedly;
  • second, a predictable lazy zone where you can walk the associations within one session;
  • third, architectural clarity: it is not obvious where the read scenario ends and where the write scenario begins.

That is why a good style is to have read services (they are often called QueryService, ReadService, *QueryFacade) and mark their methods with @Transactional(readOnly=true). Then you get one honest boundary: “I read everything needed for the response and leave.”

A mini example for our project. We want to read only the order status, without touching anything else:

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

@Service
public class OrderQueryService {

    private final PurchaseOrderRepository orderRepository; // we use the repository for reading only

    public OrderQueryService(PurchaseOrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional(readOnly = true) // define the read-use-case boundary (lazy access inside is possible)
    public String loadOrderStatus(Long orderId) {
        return orderRepository.findById(orderId).orElseThrow().getStatus().name();
    }
}

This code is good because it is “honest”: the annotation makes it clear that this is reading. And if, in six months, someone decides “let’s also change the status here,” they will have to do it explicitly — and they themselves will see that the read-only boundary becomes methodologically wrong.

For contrast, here is a typical “bad” variant. It may not even blow up immediately, but it is methodologically dangerous: it reads like a read use case, but does a write:

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

@Service
public class CatalogService {

    @Transactional(readOnly = true)
    public void hideProductWrong(Long productId) {
        // productRepository is assumed to be an injected dependency here (omitted for brevity)
        Product product = productRepository.findById(productId).orElseThrow();
        product.setStatus(ProductStatus.HIDDEN); // inside a read-only method, we change a managed entity (breaking the contract)
    }
}

The correct fix is not to “configure Hibernate,” but simply to honestly call the scenario a write:

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

@Service
public class CatalogService {

    @Transactional // this is a write use case: changes are expected and should be saved
    public void hideProduct(Long productId) {
        Product product = productRepository.findById(productId).orElseThrow();
        product.setStatus(ProductStatus.HIDDEN);
    }
}

And here it is important not to confuse things: readOnly=true is not “for decoration.” It is part of your method’s contract.

4. Read-only and Hibernate: dirty checking and flush

When you first hear “read-only transaction,” you want to ask: “So does Hibernate really stop tracking changes?” And in most practical configurations, the answer will be: yes, Spring really does try to optimize Hibernate behavior in read-only transactions. But this is still not a steel-reinforced protection against writing, only a shift of Hibernate into a mode of “I do not expect changes; I will work more economically.”

Let us break it down using the course’s familiar terms. We already know that Hibernate takes a snapshot for managed entities and runs dirty checking on flush. In a normal write transaction that makes sense: we expect you to change entities. In a read-only scenario, that is unnecessary work: you loaded data, transformed it into a DTO, and left. That means snapshots and the “before/after” comparison can be redundant.

That is why Spring, with @Transactional(readOnly=true), usually does two things for Hibernate: it reduces the likelihood of automatic flush and it switches the session/query into read-only expectations. As a result, Hibernate more often behaves as if you said: “Do not waste effort looking for changes; I am just reading.”

Let us show this with a simple diagnostic snippet. Sometimes, for teaching purposes, it is useful to look directly at what Hibernate thinks about flush mode inside a transaction:

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

@Transactional(readOnly = true)
public void debugReadOnlyMode() {
    // entityManager is assumed to be an injected EntityManager here (field/constructor omitted to keep the focus on the idea)
    Session session = entityManager.unwrap(Session.class); // obtain the native Hibernate Session
    System.out.println("defaultReadOnly = " + session.isDefaultReadOnly()); // often: true
}

If you enable the sql-trace profile and watch the log carefully, you will notice a characteristic thing: in read-only scenarios, you often see only SELECT, without sudden UPDATE statements that sometimes appear because of accidental changes in a managed graph. But here we need to state an important danger point plainly.

Read-only is not a license to write dirty code. If, inside a read-only method, you accidentally call a setter, you can get one of the most unpleasant situations in engineering practice: in memory, the object changed, but in the database it did not. And you may leave the method with the feeling that “everything changed,” because you were looking at the Java object. But in the next query to the database, you will discover that you did not write anything. And that is not “Hibernate broke,” that is you mixing two worlds: the read use case and the write use case.

It is also worth saying a separate word about flush. We discussed that flush and commit are different things. In a read-only transaction, Hibernate and Spring usually behave so that flush happens less often. That is useful, but again, do not turn it into a religion. If you manually call entityManager.flush() or do saveAndFlush() (please do not do this in a read-only scenario), you can get SQL even in “read-only” mode, because you explicitly asked for synchronization. Hibernate is not a psychic: if you explicitly demand a flush, it will try to do it.

Finally, readOnly can propagate down to the JDBC level as a “connection read-only” flag. Some drivers and databases may use this for optimization, but it is not always a strict ban on INSERT/UPDATE/DELETE. In PostgreSQL, there is a “transaction read only” mode, but Spring, out of the box, more often treats readOnly as a hint. So the correct engineering position is this: readOnly=true helps both in meaning and in runtime cost, but it is not your “security barrier” against writing.

A read-only transaction should be read-only in meaning, and Hibernate optimizations are a pleasant bonus.

5. Read-only and lazy loading without OSIV

With lazy loading, everything is very clear here: proxies and collections live perfectly well while there is an active session, and suddenly turn into LazyInitializationException when there is no session. This is one of those cases where “reading” suddenly becomes very much “about the transaction”: in order to read the needed fields correctly, you must be within the boundary where the persistence context is still alive.

A scenario from our domain: we need to show order details, including items (OrderItem) and some snapshots. We do not want to do this through OSIV (it is disabled), and we do not want to stretch a write transaction over a read. So we create a separate query use case and wrap it in a read-only transaction.

For example, we want to assemble a simple DTO response for an “order card”:

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

@Service
public class OrderQueryService {

    @Transactional(readOnly = true) // keep the session open only for the time needed to read + build the DTO
    public OrderCardDto loadOrderCard(Long orderId) {
        PurchaseOrder order = orderRepository.findById(orderId).orElseThrow(); // reading the entity within one transaction
        return OrderCardDto.from(order); // inside, we can carefully walk lazy associations while the transaction is active
    }
}

Notice that we return a DTO, not an entity. That is not “a law of the world,” but it is a very healthy style: the DTO fixes which data we actually read and does not carry the managed graph outside.

If, inside OrderCardDto.from(order), you go into order.getItems() and they are LAZY, then this read will happen safely — because you are inside the transaction, the session is alive, and Hibernate can execute SQL. But once you leave the method, you should already assume that “the lazy doors are closed.” That is why a read use case should “read everything needed and immediately assemble the result,” rather than handing out an object that will then be carried through layers.

It is easy to fall into a wrong assumption here: “since it is read-only, I can keep the transaction longer — I am not blocking anything anyway.” Do not. Even a read-only transaction holds resources: a connection, a context, sometimes snapshot structures. It should be short and clear: enter → read → build the result → leave.

6. Practicing readOnly=true in Commerce Persistence Lab

In practice, a good style for projects where you want predictable ORM behavior looks like this: you have command services (write use cases) and query services (read use cases). You do not necessarily have to make them separate modules, but it is useful at least at the level of packages and naming.

In our project, this is naturally expressed as follows: CheckoutService places the order and moves the state — that is an ordinary write transaction. OrderQueryService returns the status, card, and list — those are read-only transactions. This helps both the brain and Hibernate: the brain reads the code as “changes happen here,” Hibernate works in “I expect changes” mode; the brain reads the code as “reading happens here,” Hibernate works more economically.

Example: a product list for the admin panel. We do not want to change anything, but we want the reading to be predictable, and if needed, to be able to walk lazy points inside the method (or, even better, use a predesigned read). Even if we return a DTO now, we can still wrap it in a read-only boundary:

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

@Service
public class ProductQueryService {

    @Transactional(readOnly = true) // the entire list assembly happens inside one read-only transaction
    public ProductListDto loadList() {
        return new ProductListDto(productQueryRepository.findListItems());
    }
}

The important thing here is that read-only is not set “on the repository,” but on the use case. As a result, you can calmly gather data from several sources inside this method (several repositories) without turning each read into a separate mini-transaction.

Another plus is a “brake for future mistakes.” Suppose someone adds a line: “let’s also update the lastViewedAt field while reading the product list.” That is already write behavior. When the method is read-only, you immediately see that this action is going in the wrong direction. And that is a very useful signal in code review.

7. When readOnly=true gets in the way

There are two especially unpleasant classes of problems that often look like “Hibernate behaves strangely,” but in reality are caused by mixing meanings.

The first class is when, inside a read-only method, you still change a managed entity. Depending on settings, flush mode, and specific conditions, you may not see an UPDATE. It seems like you “changed the status,” but nothing changed in the database. After leaving the transaction, you lose the persistence context, then read from the database again and see the old value. It creates the feeling that “the system is glitching,” but in fact you simply violated the contract.

The second class is when you do “a little write” in a read-only method and then, inside the same method, execute another query and expect that query to see the changes. We remember from the day about flush that Hibernate may do a flush before a query to preserve correctness. In a read-only transaction, flush is often suppressed, because “there should be no writes.” As a result, the query reads old data from the database, while you are looking at changed Java objects. And inside one method, you end up with two worlds that are not synchronized: the database and memory.

This is very similar to a situation where you edit a document in Word, but forget to click “Save,” and then open the file again and are surprised. Only here it is worse: Hibernate’s “save” button is called flush, and yours is “as if disabled,” because you yourself said “readOnly.”

So the main practical conclusion of this section is simple and boring, and therefore correct: a read-only method should be read-only in meaning. If you feel the temptation to “read and update a little,” make it an ordinary write transaction, and move reading/data assembly into a separate read use case if needed.

8. Typical mistakes

Mistake #1: thinking that readOnly=true is an “accelerator” that can be put anywhere.
Sometimes people slap it on any method that “kind of reads a lot,” even if there is a status change, entity creation, or a save() call inside. As a result, the code starts contradicting itself: by annotation it is read-only, by meaning it is write. This breaks predictability and makes behavior depend on flush mode nuances rather than business logic.

Mistake #2: thinking that read-only makes Java objects immutable.
In Java, you can still call a setter, change a field, add an element to a collection. Read-only is not magic of the language; it is a transaction mode and an infrastructure expectation. So you cannot “protect” yourself from programmer mistakes with read-only; you need to write the code so that in a read use case there are simply no places where you change anything.

Mistake #3: mixing reading and writing in one method “because it is convenient.”
The most dangerous version is: read an entity, change a field, then do another query and expect the query to see the changes. In a read-only scenario, flush may not happen automatically, and you get a mismatch between the database and memory. Even if it “sometimes works,” on another environment or another dependency version it may behave differently.

Mistake #4: hoping that readOnly is a strict ban on UPDATE/INSERT/DELETE at the database level.
In most Spring configurations, readOnly is a hint, and although Hibernate and JDBC may behave more economically, that does not necessarily turn into “SET TRANSACTION READ ONLY” in PostgreSQL. If you need strict protection, it is solved by a separate setting and separate discipline, but within our course the main protection is correct design of boundaries and scenarios.

Mistake #5: not putting a service-level read-only boundary where a read consists of several steps.
If your read use case performs several queries, builds a DTO, checks related data, and you do not have @Transactional(readOnly=true) on the service method, the read can split into several repository-level mini-transactions. This reduces persistence context predictability, increases repeated reads, and more often leads to lazy problems with open-in-view=false.

1
Task
Hibernate deep-dive, level 17, lesson 2
Locked
Read-only service for reading order status
Read-only service for reading order status
1
Task
Hibernate deep-dive, level 17, lesson 2
Locked
Product card with a LAZY association inside a read-only transaction
Product card with a LAZY association inside a read-only transaction
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION