What are the propagation and enforcement pitfalls of @Transactional(readOnly = true)? When does the flag actually take effect?
answer
- Bound to the physical transaction, set at begin
- REQUIRED joins => inner readOnly ignored
- REQUIRES_NEW/NESTED-start => inner applies
- Self-invocation bypasses proxy entirely
- Enforcement = DB role/replica, not the flag
basics
~20 sreadOnly is set when a physical transaction begins. If an inner method joins an existing (REQUIRED) transaction, its own readOnly value is ignored — the outer transaction's flag wins. And because it is only a hint, real no-write guarantees must come from the DB, not this flag.
solid answer
~40 sTwo principal-level traps. First, readOnly is bound to the *physical* transaction and is evaluated when that transaction is created. With PROPAGATION_REQUIRED, an inner @Transactional method that joins an existing transaction does NOT get its own readOnly honored — the outer transaction's readOnly wins; Spring even warns/can reject if you demand a stronger setting. Only propagation that starts a new physical transaction (REQUIRES_NEW, NESTED, or the outer-most call) actually applies the inner flag. Second, self-invocation: calling a @Transactional method from within the same bean bypasses the proxy, so the annotation (including readOnly) is ignored entirely. Third, it is never enforcement: to truly forbid writes use a read-only DB role or replica. Design principle: put readOnly at the boundary where the physical transaction begins, and never rely on it for authorization or data safety.
code
java · 17 lines@Service
public class OrderService {
@Transactional // read-write physical tx starts here
public void process(Long id) {
// Inner method joins THIS tx (REQUIRED). Its readOnly=true is IGNORED;
// the connection stays read-write, no replica routing, auto-flush stays on.
Report r = auditService.buildReport(id);
// ...
}
}
@Service
public class AuditService {
@Transactional(readOnly = true) // honored ONLY if it starts a physical tx
public Report buildReport(Long id) { /* ... */ }
}go deeper
Just knows it is a hint set on a transaction.
Knows self-invocation and default propagation can make it a no-op.
Explains REQUIRED-joins-ignore vs REQUIRES_NEW applies, and boundary placement.
Designs where the flag lives, ties it to replica topology, and enforces data safety at the DB layer instead.
## Where and when the flag is evaluated `readOnly` is a property of the *physical* transaction, applied by the transaction manager at `beginTransaction`. Once a physical transaction exists, its read-only characteristic is fixed for its lifetime. ## Propagation trap (the big one) Spring propagation controls whether a method joins an existing transaction or starts a new one: - **REQUIRED (default)** — if a transaction already exists, the method *joins* it. The inner method's `readOnly`, `isolation`, and `timeout` are **not** applied; the outer transaction's settings govern. If the outer is read-write and the inner declares `readOnly = true`, the inner does not become read-only. Spring may even raise an error if `validateExistingTransaction` is on and you request an incompatible read-only setting. - **REQUIRES_NEW** — suspends the outer and starts a *new* physical transaction, so the inner `readOnly` **is** applied (and can route to a replica, etc.). - **NESTED** — uses a savepoint within the same physical transaction; the read-only flag of the outer still governs the connection. Consequence: annotating a low-level repository method `readOnly = true` is often useless if it is always called inside an outer read-write service transaction. Put the flag at the outermost boundary that opens the transaction. ## Self-invocation / proxy trap Spring transactions are AOP proxy-based. Calling `this.someReadOnlyMethod()` from another method of the same bean does not go through the proxy, so **no** `@Transactional` attribute — including `readOnly` — takes effect. The call runs in whatever transaction the caller already has (or none). ## It is a hint, never enforcement Even when applied, `readOnly` does not authorize or forbid anything. For a real guarantee that a path cannot mutate data: - Use a **database user/role with no INSERT/UPDATE/DELETE grants** for read connections. - Route reads to a **replica / hot standby** that physically rejects writes. - Keep read and write services architecturally separate. ## Design guidance (principal lens) - Set `readOnly = true` at the service boundary where the physical transaction starts, typically class-level with write methods overriding to `@Transactional`. - Do not scatter it on inner/repository methods expecting per-call effect under REQUIRED. - Treat it as optimization + routing signal + documentation; enforce data safety with DB permissions and replica topology. - Be mindful that switching a boundary to readOnly can change replica routing and thus expose replication-lag/read-your-writes issues.
- An inner readOnly method is always called inside an outer read-write transaction. Does readOnly do anything?No. Under PROPAGATION_REQUIRED it joins the outer physical transaction, so its readOnly, isolation, and timeout are ignored — the outer read-write settings govern. Use REQUIRES_NEW to force a separate read-only physical transaction.
- How do you guarantee a code path truly cannot write, not just 'usually won't'?Enforce outside Spring: a DB user/role without write grants, routing to a read replica that rejects writes, or separating read and write services. readOnly is a hint, not authorization.
- Why can readOnly=true silently do nothing on a method?Self-invocation: calling it from another method of the same bean skips the AOP proxy, so no @Transactional attribute applies at all.
saying these in an interview costs you the question
- Assuming inner readOnly overrides an outer read-write transaction under REQUIRED
- Relying on readOnly on a self-invoked method
- Using readOnly as a data-safety/authorization guarantee
- Thinking readOnly per-repository-call always routes to a replica