Back to Transaction Internals
Q1Question 1 of 2

What happens internally when @Transactional executes?

Key Concepts
  • A CGLIB proxy intercepts calls to @Transactional methods and delegates to TransactionInterceptor
  • TransactionInterceptor reads annotation attributes (propagation, isolation, timeout, rollbackFor) via AnnotationTransactionAttributeSource
  • TransactionAspectSupport.invokeWithinTransaction selects the PlatformTransactionManager and calls getTransaction() to begin
  • The manager acquires a connection from HikariCP, disables auto-commit, and binds the connection to the thread via TransactionSynchronizationManager
  • All repository calls share the same thread-bound connection during the method execution
  • On normal return the manager commits; on RuntimeException or Error it rolls back; then the connection is unbound and returned to the pool

Definition

The CGLIB proxy intercepts the call, TransactionInterceptor reads the annotation attributes, PlatformTransactionManager acquires a connection and binds it to the thread, the method runs on that connection, and on return the manager commits or rolls back.
  • CGLIB proxy -- Spring Boot's default proxy style that creates a subclass of the target bean and intercepts method calls.
  • TransactionInterceptor -- an AOP MethodInterceptor that is the proxy's advice for @Transactional methods; it reads annotation attributes and coordinates the transaction.
  • AnnotationTransactionAttributeSource -- the component that parses @Transactional attributes (propagation, isolation, timeout, readOnly, rollbackFor) into a TransactionAttribute object.
  • TransactionAspectSupport.invokeWithinTransaction -- the helper method that selects the PlatformTransactionManager, begins the transaction, invokes the real method, and commits or rolls back.
  • Thread-local binding -- TransactionSynchronizationManager uses ThreadLocal to bind the connection to the current thread so all DAO calls share one connection.

At the National Bank of India, the security gate (CGLIB proxy) intercepts every visitor. The head of security (TransactionInterceptor) reads the visitor's pass (annotation attributes). The vault manager (PlatformTransactionManager) opens the vault (begin transaction), hands the key to the teller's belt (thread-local binding). The teller processes the transfer: every vault operation uses the same key. When done, the vault manager closes the vault (commit) or cancels everything (rollback) and takes back the key (unbind).

Understanding the Concepts

What happens internally when @Transactional executes?

The end-to-end chain starts when a caller invokes a @Transactional method on a Spring bean. The bean is not the actual object but a CGLIB proxy. The proxy intercepts the call and delegates to its advice, which is TransactionInterceptor.

The interceptor reads the transaction attributes from the method annotation using AnnotationTransactionAttributeSource. This parsing produces a TransactionAttribute object containing the propagation mode, isolation level, timeout, read-only flag, and rollback rules.

The interceptor then calls TransactionAspectSupport.invokeWithinTransaction(...), which selects the right PlatformTransactionManager and executes the method within a transaction boundary.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class TransferService {

    private final AccountRepository accountRepo;

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

    @Transactional(
        propagation = Propagation.REQUIRED,
        isolation = Isolation.READ_COMMITTED,
        timeout = 10,
        rollbackFor = Exception.class
    )
    public void transfer(String fromId, String toId, BigDecimal amount) {
        accountRepo.debit(fromId, amount);
        accountRepo.credit(toId, amount);
    }
}
TEXT
Internal call chain:
  1. caller.transfer("SAV2001", "CUR3001", amount)
  2. CGLIB proxy intercepts
  3. TransactionInterceptor.invoke()
  4.   -> reads attributes: REQUIRED, READ_COMMITTED, timeout=10
  5.   -> TransactionAspectSupport.invokeWithinTransaction()
  6.     -> manager.getTransaction(attribute)
  7.       -> HikariCP.getConnection()
  8.       -> connection.setAutoCommit(false)
  9.       -> TransactionSynchronizationManager.bindResource()
  10.    -> real method executes: debit() + credit()
  11.    -> manager.commit() or manager.rollback()
  12.    -> unbind resource, return connection to pool

How does TransactionInterceptor read the annotation attributes?

TransactionInterceptor delegates to AnnotationTransactionAttributeSource to parse the @Transactional annotation on the method or class. The source checks the target method first, then the target class, then the interface method, then the interface class. This lookup order is why placing the annotation on the implementation always works.

The parsed attributes include:

  • propagation -- REQUIRED, REQUIRES_NEW, NESTED, etc.
  • isolation -- READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE, etc.
  • timeout -- seconds before the transaction times out.
  • readOnly -- hint for optimization.
  • rollbackFor / noRollbackFor -- which exceptions trigger rollback.
JAVA
package com.bank.debug;

import org.springframework.stereotype.Component;
import org.springframework.transaction.interceptor.TransactionAttribute;
import org.springframework.transaction.interceptor.TransactionAttributeSource;
import java.lang.reflect.Method;

@Component
public class AttributeInspector {

    private final TransactionAttributeSource attributeSource;

    public AttributeInspector(
            TransactionAttributeSource attributeSource) {
        this.attributeSource = attributeSource;
    }

    public void inspect(Class<?> clazz, String methodName)
            throws NoSuchMethodException {
        Method method = clazz.getMethod(methodName,
            String.class, String.class, java.math.BigDecimal.class);
        TransactionAttribute attr =
            attributeSource.getTransactionAttribute(method, clazz);
        if (attr != null) {
            System.out.println("Propagation: "
                + attr.getPropagationBehavior());
            System.out.println("Isolation: "
                + attr.getIsolationLevel());
            System.out.println("Timeout: " + attr.getTimeout());
            System.out.println("ReadOnly: " + attr.isReadOnly());
        }
    }
}
TEXT
Output for TransferService.transfer:
  Propagation: 0  (REQUIRED)
  Isolation: 2    (READ_COMMITTED)
  Timeout: 10
  ReadOnly: false

How does invokeWithinTransaction work?

TransactionAspectSupport.invokeWithinTransaction(...) is the core helper that coordinates the entire transaction lifecycle. It selects the PlatformTransactionManager, begins the transaction, invokes the real method, and decides whether to commit or rollback based on the outcome.

JAVA
// Simplified pseudocode of invokeWithinTransaction
public Object invokeWithinTransaction(Method method, Object target,
        InvocationCallback invocation) {

    // 1. Select the PlatformTransactionManager
    PlatformTransactionManager manager = determineTransactionManager();

    // 2. Begin: get connection, setAutoCommit(false), bind to thread
    TransactionStatus status = manager.getTransaction(attribute);

    try {
        // 3. Invoke the real method
        Object result = invocation.proceedWithInvocation();

        // 4. Commit on success
        commitTransactionAfterReturning(status);
        return result;

    } catch (Throwable ex) {
        // 5. Rollback on matching exception
        completeTransactionAfterThrowing(status, ex);
        throw ex;

    } finally {
        // 6. Cleanup: unbind, return connection to pool
        cleanupTransactionInfo();
    }
}

How does the PlatformTransactionManager begin a transaction?

The manager's getTransaction() method handles the begin step. For JpaTransactionManager (the default in Spring Boot with JPA), it acquires a connection from HikariCP, disables auto-commit, and binds the connection to the current thread via TransactionSynchronizationManager.

JAVA
package com.bank.debug;

import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;

@Component
public class TxInternalsDemoRunner implements CommandLineRunner {

    @Override
    @Transactional
    public void run(String... args) {
        System.out.println("TX active: "
            + TransactionSynchronizationManager
                .isActualTransactionActive());
        System.out.println("TX read-only: "
            + TransactionSynchronizationManager
                .isCurrentTransactionReadOnly());
        System.out.println("TX name: "
            + TransactionSynchronizationManager
                .getCurrentTransactionName());
    }
}
TEXT
Output:
  TX active: true
  TX read-only: false
  TX name: com.bank.debug.TxInternalsDemoRunner.run

Why does self-invocation bypass the proxy?

The entire mechanism lives in the proxy. When you call this.method() from inside the same bean, the call goes directly to the target object, bypassing the proxy and the TransactionInterceptor. No transaction is started.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class LoanService {

    private final LoanRepository loanRepo;

    public LoanService(LoanRepository loanRepo) {
        this.loanRepo = loanRepo;
    }

    public void processLoan(String loanId, BigDecimal amount) {
        // WRONG: self-invocation bypasses proxy
        this.approveLoan(loanId, amount);
    }

    @Transactional
    public void approveLoan(String loanId, BigDecimal amount) {
        loanRepo.approve(loanId, amount);
        // No transaction started! Proxy was not involved.
    }
}
TEXT
External call path:
  caller -> Proxy -> TransactionInterceptor -> approveLoan() [TX]

Self-invocation path:
  processLoan() -> this.approveLoan() [NO TX]
  Proxy is SKIPPED, TransactionInterceptor never runs

How does commit and rollback work?

On a normal return, TransactionAspectSupport calls commitTransactionAfterReturning(status). The manager calls connection.commit(), unbinds the connection from the thread, and returns it to HikariCP.

On an exception, completeTransactionAfterThrowing(status, ex) checks if the exception matches the rollbackFor rules. By default, RuntimeException and Error trigger rollback; checked exceptions do not. This is a common source of bugs.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class RollbackDemoService {

    private final AccountRepository accountRepo;

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

    // RuntimeException: rolls back by default
    @Transactional
    public void transferWithRuntimeException(String fromId,
            String toId, BigDecimal amount) {
        accountRepo.debit(fromId, amount);
        throw new RuntimeException("Simulated failure");
        // Spring rolls back: debit is undone
    }

    // Checked exception: does NOT rollback by default!
    @Transactional
    public void transferWithCheckedException(String fromId,
            String toId, BigDecimal amount) throws Exception {
        accountRepo.debit(fromId, amount);
        throw new Exception("Checked failure");
        // Spring COMMITS: debit is NOT undone!
    }

    // Fix: explicitly include checked exceptions
    @Transactional(rollbackFor = Exception.class)
    public void transferWithCheckedFixed(String fromId,
            String toId, BigDecimal amount) throws Exception {
        accountRepo.debit(fromId, amount);
        throw new Exception("Checked failure");
        // Spring rolls back: debit is undone
    }
}
TEXT
Rollback rules (default):
  RuntimeException -> ROLLBACK
  Error            -> ROLLBACK
  Checked Exception -> COMMIT (surprise!)

Fix: @Transactional(rollbackFor = Exception.class)
  All exceptions -> ROLLBACK

What are common mistakes?

The most common mistake is calling a @Transactional method from within the same class (self-invocation). The second is assuming checked exceptions trigger rollback.

JAVA
// WRONG: self-invocation
public class AccountService {
    public void outer() {
        this.inner(); // No TX -- proxy bypassed
    }

    @Transactional
    public void inner() {
        // No transaction started
    }
}

// CORRECT: call from outside or use a separate bean
@Service
public class AccountOrchestrator {
    private final AccountService accountService;

    public void outer() {
        accountService.inner(); // Proxy intercepts -- TX started
    }
}

What should a senior engineer know?

A senior engineer knows that the CGLIB proxy, TransactionInterceptor, AnnotationTransactionAttributeSource, TransactionAspectSupport, and TransactionSynchronizationManager are the five components that form the @Transactional pipeline. Understanding this chain lets you debug issues like missing transactions, unexpected commits, and connection leaks.

Senior engineers also know that @Transactional on a @Bean factory method does not make the factory method transactional: it sets the transaction attribute on the returned bean's methods. And they know that for reactive applications (WebFlux), a completely different mechanism (ReactiveTransactionManager) is used because ThreadLocal binding does not work with reactive streams.

JAVA
package com.bank.debug;

import org.springframework.transaction.support.TransactionSynchronizationManager;

public class TxDebugUtils {

    public static void printTxState() {
        System.out.println("Active TX: "
            + TransactionSynchronizationManager
                .isActualTransactionActive());
        System.out.println("Resources bound: "
            + TransactionSynchronizationManager
                .getResourceMap().size());
        System.out.println("Synchronizations: "
            + TransactionSynchronizationManager
                .getSynchronizations().size());
    }
}

What are common interview follow-up questions?

  • What are the five components in the @Transactional internal pipeline?
  • Why does self-invocation bypass the proxy?
  • What is the default rollback behavior for checked vs unchecked exceptions?
  • How does REQUIRES_NEW propagation suspend the current transaction internally?
  • What is the difference between JpaTransactionManager and DataSourceTransactionManager?

Visual Overview

DIAGRAM
  External Call Chain:

  Caller
    │
    ▼
  ┌──────────────────────────────────────┐
  │ CGLIB Proxy                          │
  │  (TransferService$$CGLIB)            │
  └──────────────┬───────────────────────┘
                 │
                 ▼
  ┌──────────────────────────────────────┐
  │ TransactionInterceptor.invoke()     │
  │  -> reads @Transactional attributes  │
  │  -> propagation, isolation, timeout  │
  └──────────────┬───────────────────────┘
                 │
                 ▼
  ┌──────────────────────────────────────┐
  │ TransactionAspectSupport             │
  │  .invokeWithinTransaction()          │
  │  -> select PlatformTransactionManager│
  │  -> manager.getTransaction()         │
  └──────────────┬───────────────────────┘
                 │
                 ▼
  ┌──────────────────────────────────────┐
  │ PlatformTransactionManager           │
  │  -> HikariCP.getConnection()         │
  │  -> connection.setAutoCommit(false)  │
  │  -> bind to ThreadLocal              │
  └──────────────┬───────────────────────┘
                 │
                 ▼
  ┌──────────────────────────────────────┐
  │ Real Method Executes                 │
  │  debit() + credit()                  │
  │  (same thread-bound connection)      │
  └──────────────┬───────────────────────┘
                 │
         ┌───────┴───────┐
         │               │
    ┌────▼────┐    ┌─────▼─────┐
    │ COMMIT  │    │ ROLLBACK  │
    │ commit()│    │ rollback()│
    └────┬────┘    └─────┬─────┘
         │               │
         └───────┬───────┘
                 │
                 ▼
         Unbind + return
         connection to pool

Key Takeaway

When a @Transactional method is called from outside the class, the CGLIB proxy intercepts and delegates to TransactionInterceptor, which reads the annotation's attributes: propagation, isolation, timeout, readOnly, and rollbackFor. It then calls TransactionAspectSupport.invokeWithinTransaction, which selects the appropriate PlatformTransactionManager. The manager acquires a connection from HikariCP, disables auto-commit to begin the transaction, and binds the connection to the current thread via TransactionSynchronizationManager using ThreadLocal.

All subsequent repository calls within the method share this same thread-bound connection. When the method returns normally, the manager commits via connection.commit(). On RuntimeException or Error, it rolls back via connection.rollback(). Finally, the connection is unbound from the thread and returned to the pool. The five components in the pipeline are the CGLIB proxy, TransactionInterceptor, AnnotationTransactionAttributeSource, TransactionAspectSupport, and TransactionSynchronizationManager.

The single most important gotcha is self-invocation. Because the entire mechanism lives in the proxy, calling this.method() from inside the same bean bypasses the proxy entirely and no transaction is started. The second gotcha is that checked exceptions do not trigger rollback by default: you need rollbackFor = Exception.class to include them.

Remember one sentence: proxy intercepts, interceptor reads attributes, manager gets a connection and binds it to the thread, the method runs on that connection, and on return it is committed or rolled back.

---

Share
Use ← → arrow keys to navigate