skip to content

Proxy Pitfalls & Rollback Semantics

The ways declarative transactions quietly fail: self-invocation, non-public methods, checked exceptions that commit, unexpected rollbacks, lazy loading past the boundary, work on other threads, and pool deadlocks. This is where most real transaction bugs — and most transaction interview questions — come from.

part ofSpring Frameworkoverview, primer and where to startread it →
on this pageshow

explore

questions

page 1 of 2

If you call an @Async method from within a @Transactional method, does the async code run inside the same transaction?

level: juniorimportance: must knowfreq 35%

answer

  1. Transaction is thread-bound (ThreadLocal)
  2. @Async = different pool thread
  3. New/independent tx, not caller's
  4. Outer rollback won't undo it
  5. Connection can't be shared across threads

basics

~20 s

No. @Async runs the method on a different thread, and Spring ties a transaction to the thread that started it. So the async code runs in its own separate transaction (or none), not the caller's.

solid answer

~40 s

No. Spring's declarative transactions are thread-bound: the JDBC Connection / JPA EntityManager for the active transaction is stored in a ThreadLocal on the thread that opened it. @Async hands the method to a different thread from a TaskExecutor pool, and that thread has none of the caller's thread-local transaction state. So the async method starts fresh — if it (or a method it calls) is @Transactional it opens a brand-new independent transaction; otherwise it runs with no transaction. The practical consequences: the async work commits or rolls back on its own timeline, it is not rolled back if the outer method fails, and it cannot see the caller's still-uncommitted changes. Treat the async boundary as a hard transaction boundary.

code

java · 22 lines
java
@Service
public class OrderService {

    @Transactional
    public void placeOrder(Order order) {
        orderRepo.save(order);      // tx A, on THIS thread
        auditService.recordAsync(); // hops to a pool thread -> tx B (or none)
        throw new RuntimeException("boom"); // rolls back tx A only
    }
}

@Service
public class AuditService {
    @Async
    @Transactional
    public void recordAsync() {
        // Runs on a TaskExecutor thread; no thread-bound tx inherited,
        // so this opens a fresh, independent transaction that commits
        // regardless of the "boom" above.
        auditRepo.save(new AuditEntry("order placed"));
    }
}

go deeper

for a junior

Must know: @Async = different thread = separate transaction; outer rollback does not undo it.

for a middle

Should be able to name ThreadLocal / TransactionSynchronizationManager as the reason.

for a senior

Connects it to entity hand-off, LazyInitializationException, and the AFTER_COMMIT fix.

for a principal

Frames it as a deliberate design (one connection per tx) and knows the reliable-delivery patterns.

## The core idea Spring's `@Transactional` is **thread-bound**. When a transaction starts, Spring stores the transactional resources — the JDBC `Connection` (or JPA `EntityManager`/`Session`) — in a `ThreadLocal` managed by `TransactionSynchronizationManager`. Every repository/`JdbcTemplate`/`EntityManager` call on that same thread looks up that thread-local resource, which is how they all join the one transaction. `@Async` (enabled by `@EnableAsync`) makes Spring's proxy submit the method to a `TaskExecutor`, so it runs on a **different thread** from a pool. `ThreadLocal` values are per-thread and are **not** copied to that pool thread. Therefore the async method sees an empty transaction context. ## What actually happens on the async thread - If the async method (or something it calls through a Spring proxy) is `@Transactional` with the default `Propagation.REQUIRED`, since there is *no* existing transaction on this thread, a **new** transaction is opened. - If nothing on the async path is transactional, the DB work runs **non-transactionally** (auto-commit per statement). - Either way it is **independent** of the caller's transaction. ## Consequences to remember 1. **No shared rollback.** If the outer `@Transactional` method throws and rolls back, the async insert has already committed (or will) and stays in the database. 2. **No dirty reads across the boundary.** The async thread runs in a different transaction, so with normal isolation it cannot read rows the caller has written but not yet committed. 3. **Detached entities / `LazyInitializationException`.** If you pass a JPA entity to the async method, the persistence context that owned it lives on the caller's thread and may already be closed; touching a lazy association on the async thread throws `LazyInitializationException`. ## Why it's designed this way A transaction ≈ one physical DB connection. Two threads cannot safely share one connection concurrently, so Spring deliberately does not propagate the transaction across threads. This is the same reason a manually spawned `new Thread(...)`/`ExecutorService` task also loses the transaction. ## The fix in one line Do not rely on the outer transaction covering async work. Kick off the async work **after** the transaction commits (e.g. `@TransactionalEventListener(phase = AFTER_COMMIT)`), and pass **IDs, not entities**, re-fetching inside the async thread's own transaction.

  • Does a manually created `new Thread(...)` behave differently from @Async here?
    No. Both run on a thread that doesn't carry the caller's thread-local transaction, so both lose the transaction context the same way. @Async just uses a managed TaskExecutor pool instead of a raw thread.
  • If the async method has no @Transactional at all, what transaction does its DB write use?
    None — it runs non-transactionally (statement-level auto-commit). It still isn't part of the caller's transaction.

saying these in an interview costs you the question

  • Thinking @Async 'inherits' the caller's transaction
  • Assuming an outer rollback will undo the async insert
  • Believing the async thread can read the caller's uncommitted rows

context

open as a page

In a @Transactional Spring method, what happens to the transaction if a checked exception is thrown out of the method?

level: juniorimportance: must knowfreq 70%

basics

~10 s

By default Spring COMMITS the transaction. Spring only rolls back automatically on unchecked exceptions (RuntimeException and Error). A checked exception like IOException does not trigger a rollback unless you configure it.

open as a page

What is a LazyInitializationException, and why does it commonly appear when a controller serializes an entity returned by a service?

level: juniorimportance: must knowfreq 78%

basics

~10 s

It's a Hibernate error thrown when you access a lazily-loaded field (like a list of orders) after the database session has already closed. The service's transaction ended, so Hibernate can't fetch the data anymore.

open as a page

Why is @Transactional silently ignored when you put it on a private method?

level: juniorimportance: must knowfreq 70%

basics

~20 s

Spring adds transactions with a proxy — a wrapper object around your bean. The proxy can only intercept public methods it can override. Private methods can't be overridden, so the annotation is skipped with no error.

open as a page

What does @Transactional(propagation = REQUIRES_NEW) do, and how does it interact with the JDBC connection of an already-running transaction?

level: juniorimportance: must knowfreq 55%

basics

~20 s

REQUIRES_NEW starts a brand-new, independent transaction. If one is already running, Spring suspends it (keeps it open) and opens a second database connection for the new one, so the thread holds two connections at once.

open as a page

What happens when one method in a Spring bean calls another @Transactional method on the same object using this.method()?

level: juniorimportance: must knowfreq 70%

basics

~10 s

The @Transactional is ignored. An internal this.method() call skips Spring's proxy, so no transaction is started for the inner method. The annotation silently does nothing.

open as a page

What is UnexpectedRollbackException in Spring and when do you see the message 'Transaction silently rolled back because it has been marked as rollback-only'?

level: juniorimportance: must knowfreq 55%

basics

~20 s

It is thrown when your method finishes normally and tries to commit, but the transaction was already marked rollback-only (usually because an inner @Transactional method caught an exception). Spring refuses to commit and rolls back instead.

open as a page

Your @Transactional service saves an entity, kicks off an @Async task that inserts an audit row, then throws and rolls back. Is the audit row rolled back? Walk through what happens.

level: middleimportance: must knowfreq 45%

basics

~20 s

No, the audit row is not rolled back. The async task ran on another thread in its own transaction and committed independently, so the outer rollback (which only affects the caller's thread/transaction) leaves it in the database.

open as a page

How do you make a Spring transaction roll back when a checked exception is thrown?

level: middleimportance: must knowfreq 65%

basics

~10 s

Set rollbackFor on the annotation: @Transactional(rollbackFor = Exception.class) rolls back on any exception, or name a specific type like rollbackFor = MyCheckedException.class. Alternatively, throw an unchecked exception instead.

open as a page

What is Open-Session-In-View (OSIV), is it on by default in Spring Boot, and what are its trade-offs?

level: middleimportance: must knowfreq 72%

basics

~20 s

OSIV keeps the Hibernate session open for the entire HTTP request, so lazy loading still works while the response is being serialized. Spring Boot enables it by default. The downside is it holds the database connection longer and can hide N+1 query problems.

open as a page

Why can nesting REQUIRES_NEW inside a REQUIRED transaction exhaust a connection pool and deadlock under load? Walk through the mechanics.

level: middleimportance: must knowfreq 60%

basics

~20 s

Each thread holds two connections at once (the suspended outer plus the new inner). With a pool of N, once N threads each grab their first connection, every one needs a second that no one can release, so they all block and the pool deadlocks.

open as a page

Why exactly does an internal self-invocation bypass @Transactional, in terms of what reference the call uses?

level: middleimportance: must knowfreq 65%

basics

~10 s

Injected beans are actually proxies. Transaction logic lives in the proxy, not your object. this.method() uses the raw object reference, so it never goes through the proxy and the transaction code is skipped.

open as a page

Why does an inner @Transactional(REQUIRED) method that catches nothing still poison the whole transaction when it throws?

level: middleimportance: must knowfreq 50%

basics

~10 s

Because with propagation REQUIRED the inner method joins the outer method's single physical transaction. A participating method can't roll back just its own part, so Spring marks the whole shared transaction rollback-only.

open as a page

Your @Transactional inner method is being ignored because of self-invocation. Walk through the available fixes and their tradeoffs.

level: seniorimportance: must knowfreq 60%

basics

~20 s

Three options: extract the inner method into a separate bean and call it (cleanest); inject the bean into itself and call through that proxy reference; or switch transactions to AspectJ weaving so all calls, including this.method(), are advised.

open as a page

Explain the mechanism that makes a new thread (e.g. from @Async) lose the caller's transaction context.

level: middleimportance: should knowfreq 40%

basics

~10 s

Spring stores the current transaction's connection in a ThreadLocal (via TransactionSynchronizationManager). ThreadLocals are per-thread, so a new/pool thread has none, and thus no active transaction to join.

open as a page

Why does @Transactional fail on final methods, and how does this bite Kotlin developers specifically?

level: middleimportance: should knowfreq 55%

basics

~20 s

CGLIB proxies work by creating a subclass that overrides your methods. A final method can't be overridden, so the transaction advice never attaches. In Kotlin, classes and methods are final by default, so this happens unless you open them.

open as a page

Between JDK dynamic proxies and CGLIB, which method-visibility and finality constraints apply to @Transactional, and how do the two strategies differ?

level: middleimportance: should knowfreq 35%

basics

~20 s

Both only advise public methods in proxy mode. JDK proxies need an interface and advise only public interface methods; CGLIB subclasses the class and can't override private, final, or static methods. Either way, private/final/self-invoked calls are skipped.

open as a page

In production you see 'UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only'. How do you diagnose the root cause?

level: middleimportance: should knowfreq 40%

basics

~20 s

Look for an inner @Transactional method that threw a RuntimeException which some outer method caught and swallowed. The swallowed exception marked the shared transaction rollback-only; find where it was caught by tracing the transactional call chain and logs.

open as a page

You pass a managed JPA entity into an @Async method and get LazyInitializationException plus intermittent stale reads. Explain the root cause and the correct way to hand work across the async/transaction boundary.

level: seniorimportance: should knowfreq 35%

basics

~20 s

The entity is tied to the caller's persistence context, which lives on the caller's thread and may already be closed. On the async thread the entity is detached, so lazy associations throw. Pass the entity's ID instead and re-load it inside the async method's own transaction.

open as a page

Walk through the internal mechanism that decides commit vs. rollback in Spring, and why a checked exception commits by default.

level: seniorimportance: should knowfreq 40%

basics

~10 s

The transaction interceptor catches the thrown exception and calls rollbackOn(). The default (DefaultTransactionAttribute) returns true only for RuntimeException and Error, so checked exceptions fall through to commit. rollbackFor adds extra RollbackRuleAttribute entries.

open as a page

With OSIV disabled, how do you correctly fetch data at the transaction boundary so serialization never hits a lazy proxy? Compare the options.

level: seniorimportance: should knowfreq 58%

basics

~20 s

Load everything the response needs while the transaction is still open. Use a JOIN FETCH query or an @EntityGraph to populate associations, or map the entity to a DTO inside the @Transactional method. Then the serializer only sees already-loaded data.

open as a page

Explain precisely when the transaction and the Hibernate Session open and close in a Spring MVC request, and how that timing relates to LazyInitializationException with and without OSIV.

level: seniorimportance: should knowfreq 46%

basics

~20 s

Without OSIV: the Session opens when a @Transactional method starts and closes when it commits/returns — before the controller serializes, so lazy access then fails. With OSIV: an interceptor opens the Session at request start and closes it after the response is written, so lazy access during serialization still works.

open as a page

CGLIB can technically override protected and package-private methods, so why does Spring still ignore @Transactional on them in proxy mode?

level: seniorimportance: should knowfreq 40%

basics

~10 s

It's a deliberate Spring policy, not a pure technical limit. In proxy mode Spring's AnnotationTransactionAttributeSource only reads transactional metadata from public methods, so protected/package-private annotations are ignored even though CGLIB could override them.

open as a page

You see production requests hanging ~30s then failing with 'Connection is not available'. You suspect nested REQUIRES_NEW. How do you confirm it and what are your fix options?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Confirm via thread dumps (threads parked in HikariPool.getConnection) and Hikari metrics (active pinned at max, pending rising). Fix by removing the nesting: run the inner work in a separate transaction before/after the outer, publish it as an event/outbox, offload it, or as a stopgap raise the pool size.

open as a page

How does switching Spring's transaction management to AspectJ weaving mode eliminate the self-invocation limitation, and what does it cost?

level: seniorimportance: should knowfreq 40%

basics

~20 s

AspectJ weaves the transaction advice directly into the class bytecode instead of wrapping the bean in a proxy. Since there's no separate proxy object, even this.method() self-calls are intercepted. Cost: a weaver agent or build step.

open as a page

You have a batch loop that processes many items, catches per-item failures, and continues — but it throws UnexpectedRollbackException at the end. How do you fix it?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Give each item its own transaction. Move the per-item work into a separate bean method annotated @Transactional(propagation = REQUIRES_NEW) so one item's failure rolls back only that item, not the whole batch, and doesn't mark a shared transaction rollback-only.

open as a page

Design a reliable 'perform this background action only if the transaction commits' mechanism. Discuss the async/transaction boundary, ordering, failure modes, and delivery guarantees.

level: principalimportance: should knowfreq 25%

basics

~20 s

Publish a domain event inside the transaction; handle it with @TransactionalEventListener(AFTER_COMMIT) so it runs only on commit. For at-least-once reliability across crashes, write an outbox row in the same transaction and have a separate dispatcher deliver it after commit.

open as a page

Kotlin has no checked exceptions, and your codebase is Kotlin. Does the 'checked exception commits by default' pitfall still apply, and how should you set a team-wide policy?

level: principalimportance: should knowfreq 25%

basics

~20 s

Yes it can still apply. Spring tests the runtime type, not the throws keyword. A Kotlin exception extending java.lang.Exception (not RuntimeException) still commits by default. In practice most Kotlin exceptions extend RuntimeException, so they roll back — but relying on that is fragile; set an explicit policy.

open as a page

As a tech lead, would you keep OSIV enabled or disable it for a high-throughput REST service? Justify the decision, its risks, and how you'd operationalize the change.

level: principalimportance: should knowfreq 34%

basics

~20 s

For a high-throughput REST API I'd disable OSIV. It frees the database connection sooner, exposes hidden N+1 queries during development, and enforces a clean DTO boundary. The trade-off is more upfront fetching code, which I'd standardize with DTO mapping and fetch tests.

open as a page

As an architect, how would you size connection pools and design the transaction topology of a service so that patterns like nested REQUIRES_NEW cannot deadlock it, and how do you enforce that at scale?

level: principalimportance: should knowfreq 30%

basics

~20 s

Cap the maximum connections any single request can hold at one time (ideally one), and size the pool to peak concurrent requests times that per-request hold, staying within the database's connection ceiling. Ban mid-transaction nesting via review/architecture tests, prefer after-commit events/outbox, and give any unavoidable second-connection work a dedicated pool.

open as a page

showing 1–30 of 33