Back to Events & Transaction Callbacks
Q2Question 2 of 3

What is readOnly optimization and @TransactionalEventListener phases?

Key Concepts
  • readOnly=true switches Hibernate flush mode to MANUAL, skipping dirty-checking snapshot comparisons at commit time
  • The JDBC driver receives a read-only hint that some databases use to route queries to a read replica
  • readOnly is a hint, not a lock: writes are not blocked, just less optimized
  • AFTER_COMMIT runs only after a successful commit and is the safest phase for SMS, email, and Kafka side effects
  • BEFORE_COMMIT runs while the TX is still open so throwing there triggers a rollback
  • AFTER_ROLLBACK fires only on rollback for failure logging; AFTER_COMPLETION fires regardless of outcome

Definition

readOnly=true skips Hibernate dirty-checking and hints replica routing for read-heavy queries, while @TransactionalEventListener phases let you fire side effects at the right moment in the transaction lifecycle.
  • readOnly=true -- a @Transactional attribute that switches Hibernate's flush mode to MANUAL, skipping the snapshot-comparison dirty check at commit time, which saves CPU and memory on read-heavy endpoints.
  • Replica routing -- a routing DataSource can inspect the read-only flag and direct the query to a read replica, keeping the primary free for writes.
  • AFTER_COMMIT -- the default phase of @TransactionalEventListener, fires only after a successful commit, making it safe for external side effects.
  • BEFORE_COMMIT -- fires just before commit while the TX is still open; throwing here triggers a rollback.
  • AFTER_ROLLBACK -- fires only on rollback for failure handling.
  • fallbackExecution -- when true, the listener runs immediately if no active transaction exists instead of being silently dropped.

At the National Bank of India, the reporting desk (readOnly=true) pulls account statements without ever writing anything. The bank does not prepare reversal paperwork for this desk because no changes will happen, which saves time. Meanwhile, the SMS dispatch team waits for the vault manager to stamp the final receipt (AFTER_COMMIT) before texting Rajesh, so the customer never receives a confirmation for a transfer that did not actually go through.

Understanding the Concepts

What does readOnly=true optimize?

readOnly=true is an optimization hint on @Transactional that tells the persistence layer and the JDBC driver that no data modifications will happen in this transaction. It is a hint, not an enforcement mechanism: writes are not blocked, but the runtime skips work that is only needed for writes.

At the Hibernate level, readOnly=true switches the flush mode to MANUAL. Normally, Hibernate takes a snapshot of every loaded entity and compares it to the current state at commit time (dirty checking). With flush mode set to MANUAL, Hibernate skips this snapshot and comparison entirely, saving CPU and memory on read-heavy endpoints.

At the JDBC level, the driver receives a setReadOnly(true) call. Some databases use this hint to route the query to a read replica or to apply internal optimizations.

JAVA
package com.bank.service;

import com.bank.model.Account;
import com.bank.repository.AccountRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.util.List;

@Service
public class AccountReportService {

    private final AccountRepository accountRepo;

    public AccountReportService(AccountRepository accountRepo) {
        this.accountRepo = accountRepo;
    }

    // readOnly=true: no dirty checking, routes to replica
    @Transactional(readOnly = true)
    public List<Account> getHighValueAccounts() {
        return accountRepo.findByBalanceGreaterThan(
            new BigDecimal("1000000"));
    }

    // Default: dirty checking enabled, routes to primary
    @Transactional
    public void creditInterest(String accountId, BigDecimal interest) {
        Account account = accountRepo.findById(accountId)
            .orElseThrow();
        account.setBalance(account.getBalance().add(interest));
        // Hibernate dirty-checks and flushes the change at commit
    }
}
TEXT
readOnly=true (getHighValueAccounts):
  Hibernate flush mode: MANUAL
  Dirty checking: SKIPPED
  JDBC hint: connection.setReadOnly(true)
  Routing DataSource: sends to read replica

readOnly=false (creditInterest):
  Hibernate flush mode: AUTO
  Dirty checking: ACTIVE (snapshot + comparison)
  JDBC hint: connection.setReadOnly(false)
  Routing DataSource: sends to primary

Why does readOnly=true save performance?

For each entity loaded in a normal transaction, Hibernate takes a deep copy (snapshot) at load time and stores it in the persistence context. At flush time, it compares every loaded entity against its snapshot field by field. For a report query that loads 10,000 account rows, that is 10,000 snapshots and 10,000 comparisons that are completely unnecessary because nothing will be modified. readOnly=true eliminates this overhead entirely.

JAVA
package com.bank.service;

import com.bank.model.Transaction;
import com.bank.repository.TransactionRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@Transactional(readOnly = true)  // Class-level default: all queries read-only
public class StatementService {

    private final TransactionRepository txnRepo;

    public StatementService(TransactionRepository txnRepo) {
        this.txnRepo = txnRepo;
    }

    // Uses class-level readOnly=true
    public List<Transaction> getMonthlyStatement(String accountId) {
        return txnRepo.findByAccountIdAndMonth(accountId, "2025-06");
    }

    // Override to writable for this method only
    @Transactional(readOnly = false)
    public void markStatementGenerated(String accountId) {
        txnRepo.updateStatementFlag(accountId, true);
    }
}

How does routing to a read replica work?

Spring provides AbstractRoutingDataSource, which inspects the current transaction's read-only flag and routes the query to the appropriate database.

JAVA
package com.bank.config;

import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import org.springframework.transaction.support.TransactionSynchronizationManager;

public class ReadWriteRoutingDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        boolean isReadOnly = TransactionSynchronizationManager
            .isCurrentTransactionReadOnly();
        return isReadOnly ? "replica" : "primary";
    }
}
TEXT
@Transactional(readOnly = true)  --> determineCurrentLookupKey() returns "replica"
@Transactional(readOnly = false) --> determineCurrentLookupKey() returns "primary"
@Transactional                   --> determineCurrentLookupKey() returns "primary"

How do the @TransactionalEventListener phases work?

@TransactionalEventListener has four phases that map to specific moments in the transaction lifecycle.

| Phase | When it runs | Use case |
|---|---|---|
| BEFORE_COMMIT | Just before commit, TX still open | Last-minute validation, atomic audit row |
| AFTER_COMMIT | Only after successful commit | SMS, email, Kafka publish, webhooks |
| AFTER_ROLLBACK | Only after rollback | Failure logging, alert, compensation |
| AFTER_COMPLETION | After TX ends (either way) | Cache eviction, resource cleanup |

JAVA
package com.bank.listener;

import com.bank.event.TransferCompletedEvent;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionalEventListener;
import org.springframework.transaction.event.TransactionPhase;

@Component
public class TransferEventListeners {

    private final AuditRepository auditRepo;
    private final SmsGateway smsGateway;
    private final AccountCache accountCache;

    public TransferEventListeners(AuditRepository auditRepo,
                                   SmsGateway smsGateway,
                                   AccountCache accountCache) {
        this.auditRepo = auditRepo;
        this.smsGateway = smsGateway;
        this.accountCache = accountCache;
    }

    @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
    public void auditBeforeCommit(TransferCompletedEvent event) {
        // Commits atomically with the transfer
        auditRepo.save(new AuditEntry(
            event.getFromAccountId(),
            event.getAmount(),
            "TRANSFER_INITIATED"));
    }

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void sendSmsAfterCommit(TransferCompletedEvent event) {
        smsGateway.send(event.getFromAccountId(),
            "Rs." + event.getAmount() + " transferred successfully.");
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
    public void logFailure(TransferCompletedEvent event) {
        System.out.println("Transfer rolled back: "
            + event.getFromAccountId()
            + " Rs." + event.getAmount());
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
    public void clearCache(TransferCompletedEvent event) {
        accountCache.evict(event.getFromAccountId());
    }
}

How is readOnly=true different from a regular transaction?

| Aspect | readOnly=true | readOnly=false (default) |
|---|---|---|
| Hibernate flush mode | MANUAL (no dirty check) | AUTO (snapshot + compare) |
| Entity snapshots | Not taken | Taken for every loaded entity |
| JDBC hint | connection.setReadOnly(true) | connection.setReadOnly(false) |
| Routing DataSource | Can route to replica | Routes to primary |
| Can write to DB? | Yes (hint only, not enforced) | Yes |
| Performance | Faster for reads | Normal |

JAVA
// readOnly=true does NOT block writes -- it is a hint
@Transactional(readOnly = true)
public void surprisingWrite(String accountId) {
    Account account = accountRepo.findById(accountId).orElseThrow();
    account.setBalance(BigDecimal.ZERO);
    // Hibernate will NOT flush this because flush mode is MANUAL
    // The write silently does not happen -- no error, no exception
}
TEXT
With readOnly=true and Hibernate:
  account.setBalance(BigDecimal.ZERO) -- change in memory only
  Flush mode MANUAL -- Hibernate does not flush
  Result: database row is UNCHANGED, no error thrown

What are common mistakes?

The first mistake is forgetting that readOnly=true is a hint, not a lock. Developers sometimes rely on it to prevent accidental writes, but Hibernate simply skips the flush -- the write silently does not happen without any error, which can cause confusing bugs.

JAVA
// WRONG: relying on readOnly to prevent writes
@Transactional(readOnly = true)
public void freezeAccount(String accountId) {
    Account account = accountRepo.findById(accountId).orElseThrow();
    account.setStatus("FROZEN");
    // No error, no exception, but status is NOT saved
}

// CORRECT: use a writable transaction for modifications
@Transactional
public void freezeAccount(String accountId) {
    Account account = accountRepo.findById(accountId).orElseThrow();
    account.setStatus("FROZEN");
    // Hibernate dirty-checks and flushes at commit
}

The second mistake is placing readOnly=true only at the method level when the entire service is read-only. Place it at the class level and override to writable where needed.

What should a senior engineer know?

A senior engineer knows that readOnly=true has three layers of effect: Hibernate flush mode, JDBC hint, and routing DataSource. Not all databases honor the JDBC hint the same way. PostgreSQL in read-only mode rejects writes at the SQL level, while MySQL treats it as a performance hint.

Senior engineers also combine readOnly=true with @TransactionalEventListener(AFTER_COMMIT) in a clean architecture pattern. The service layer handles reads in read-only transactions and publishes domain events from write transactions. Side effects like SMS and Kafka publishing happen in AFTER_COMMIT listeners, keeping the service layer free of external dependencies.

JAVA
package com.bank.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;

@Configuration
@EnableAsync  // Required for @Async on AFTER_COMMIT listeners
public class AsyncConfig {
    // Spring uses a SimpleAsyncTaskExecutor by default
    // For production, configure a ThreadPoolTaskExecutor
}

What are common interview follow-up questions?

  • Does readOnly=true prevent writes at the SQL level or only at the Hibernate level?
  • Can you use readOnly=true with native queries?
  • What happens if a BEFORE_COMMIT listener throws an exception?
  • How do you test that readOnly=true actually routes to the replica?
  • What is the performance difference between readOnly=true and readOnly=false for a query loading 10,000 entities?

Visual Overview

DIAGRAM
  ┌─────────────────────────────────────────────┐
  │          readOnly=true Optimization           │
  │                                               │
  │  Normal TX                readOnly TX         │
  │  ┌──────────────┐        ┌──────────────┐    │
  │  │ Load entity  │        │ Load entity  │    │
  │  │ Take snapshot│        │ (no snapshot)│    │
  │  │ ...          │        │ ...          │    │
  │  │ Dirty check  │        │ (skip flush) │    │
  │  │ Flush changes│        │              │    │
  │  └──────────────┘        └──────────────┘    │
  │  CPU: HIGH                CPU: LOW            │
  └─────────────────────────────────────────────┘

  ┌─────────────────────────────────────────────┐
  │          Routing DataSource                   │
  │                                               │
  │  @Transactional(readOnly=true)               │
  │       │                                       │
  │       ▼                                       │
  │  determineCurrentLookupKey()                  │
  │       │                                       │
  │       ├── readOnly=true  ──▶  READ REPLICA   │
  │       └── readOnly=false ──▶  PRIMARY        │
  └─────────────────────────────────────────────┘

Key Takeaway

The readOnly=true flag is a performance optimization with three layers of effect. At the Hibernate level, it switches the flush mode to MANUAL so Hibernate stops taking snapshots of loaded entities and stops dirty-checking them at commit time, which saves significant CPU on read-heavy endpoints. At the JDBC level, the driver receives a setReadOnly(true) call that some databases use for internal optimizations. At the infrastructure level, a routing DataSource can inspect the flag and send the query to a read replica, keeping the primary free for writes.

In the National Bank of India reporting service, Rajesh put readOnly=true at the class level on the statement service so every query skips dirty checking and routes to the replica. He overrode to writable only where a method actually modifies data. For notifications, he never sent SMS from inside the transaction. Instead he published a domain event and handled it with @TransactionalEventListener in the AFTER_COMMIT phase, so the message was sent only after the transfer was durably committed. The BEFORE_COMMIT phase let the compliance team add a last-minute audit row that committed atomically with the transfer.

The important thing to remember is that readOnly=true is a hint, not a lock. It does not throw an error if you accidentally modify an entity; Hibernate simply does not flush the change, which can cause silent bugs. Always use a writable transaction for methods that modify data.

Remember one sentence: readOnly=true skips dirty-checking and routes to the replica, but it is a hint, not an enforcement.

---

Share
Use ← → arrow keys to navigate