Back to @Transactional Basics
Q1Question 1 of 7

How does @Transactional work?

Key Concepts
  • AOP proxy intercepts the method call, begins a transaction before and commits after
  • Connection is bound to the current thread via TransactionSynchronizationManager so all repository calls share it
  • Rollback triggers on unchecked exceptions (RuntimeException and Error) but NOT on checked exceptions by default
  • Key attributes: propagation, isolation, readOnly, timeout, rollbackFor
  • Only works on public methods called from outside the bean because the proxy cannot intercept self-invocation
  • Spring Boot defaults to CGLIB proxy, so the class and transactional methods must not be final

Definition

@Transactional is a Spring annotation that wraps a method in a database transaction through an AOP proxy that begins before the method runs, commits on success, and rolls back on a RuntimeException.
  • @Transactional – a declarative annotation that tells Spring to manage the transaction boundary around the annotated method or all public methods of the annotated class.
  • AOP Proxy – Spring creates a CGLIB subclass proxy (default in Spring Boot) that intercepts calls and runs the transaction logic before and after the real method.
  • Connection Binding – the proxy obtains a connection from HikariCP, sets auto-commit to false, and binds the connection to the current thread via TransactionSynchronizationManager (a ThreadLocal holder) so every repository call in the method shares the same connection.
  • Commit and Rollback – on normal return the proxy calls commit; on RuntimeException or Error the proxy calls rollback; on checked exceptions the proxy commits by default unless rollbackFor is specified.
  • Key Attributes – propagation (how to join or create transactions), isolation (what concurrent changes are visible), readOnly (skip dirty checking and hint for replicas), timeout (fail if the transaction exceeds N seconds), rollbackFor (which exception types trigger rollback).

Think of the National Bank of India branch. Rajesh wants to transfer Rs.50,000 from his savings account SAV2001 to Priya's account SAV3002. The @Transactional annotation is like the bank's all-or-nothing rule: debit Rajesh AND credit Priya, or do neither. The AOP proxy is the vault door that opens (begin), lets the clerk do the work (method body), and either seals the changes (commit) or undoes everything (rollback) if something goes wrong.

Understanding the Concepts

What is @Transactional?

@Transactional is a Spring Framework annotation that marks a method or an entire class as transactional. When Spring sees this annotation, it wraps the bean in an AOP proxy. Every external call to a transactional method goes through the proxy, which starts a database transaction before the method runs, commits it when the method returns normally, and rolls it back when the method throws a runtime exception.

In a banking application, the transfer method must debit one account and credit another atomically. If the credit fails, the debit must be undone. @Transactional handles this without any manual connection management.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
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
    public void transfer(String fromId, String toId, BigDecimal amount) {
        Account sender = accountRepo.findByIdForUpdate(fromId);
        Account receiver = accountRepo.findByIdForUpdate(toId);
        sender.debit(amount);
        receiver.credit(amount);
        accountRepo.save(sender);
        accountRepo.save(receiver);
    }
}

If receiver.credit(amount) throws an InsufficientFundsException that extends RuntimeException, the proxy catches it and rolls back the entire transaction, including the debit. Rajesh's balance at National Bank of India stays unchanged.

Why does @Transactional exist?

Without @Transactional, every service method that touches the database would need manual transaction management: obtain a connection, disable auto-commit, run the SQL, commit on success, roll back on failure, and close the connection in a finally block. That is fifteen or more lines of boilerplate per method, and forgetting any step causes data corruption or connection leaks.

@Transactional exists to separate the business logic from the transaction plumbing. The developer writes only the business rules, and Spring handles the connection lifecycle. This is the declarative transaction management pattern.

JAVA
package com.bank.service;

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

@Service
public class InterestService {

    private final AccountRepository accountRepo;

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

    // Without @Transactional, this would need 15+ lines of try-catch-finally
    @Transactional
    public void applyMonthlyInterest(String accountId, BigDecimal rate) {
        Account account = accountRepo.findById(accountId);
        BigDecimal interest = account.getBalance().multiply(rate);
        account.credit(interest);
        accountRepo.save(account);
    }
}

How does @Transactional work internally?

When Spring Boot starts, it detects @Transactional annotations and wraps the bean in a CGLIB proxy (a runtime-generated subclass). The proxy's method override calls TransactionInterceptor, which delegates to TransactionAspectSupport.invokeWithinTransaction(). This method uses the PlatformTransactionManager (typically JpaTransactionManager or DataSourceTransactionManager) to begin a transaction.

The flow is:

  1. External caller invokes the proxy method.
  2. Proxy obtains a database connection from HikariCP.
  3. Proxy calls connection.setAutoCommit(false) to begin the transaction.
  4. Proxy binds the connection to the current thread via TransactionSynchronizationManager (a ThreadLocal holder).
  5. Proxy calls the real method on the target bean.
  6. All repository calls on this thread look up the connection from the ThreadLocal and share it.
  7. If the method returns normally, the proxy calls connection.commit().
  8. If the method throws a RuntimeException or Error, the proxy calls connection.rollback().
  9. The proxy releases the connection back to the pool.
JAVA
package com.bank.config;

import org.springframework.transaction.support.TransactionSynchronizationManager;

// Demonstrating that the connection is thread-bound
public class ConnectionBindingDemo {

    public static void showCurrentTransaction() {
        boolean active = TransactionSynchronizationManager.isActualTransactionActive();
        String txName = TransactionSynchronizationManager.getCurrentTransactionName();
        boolean readOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();

        System.out.println("TX active: " + active);
        System.out.println("TX name: " + txName);
        System.out.println("TX readOnly: " + readOnly);
    }
}
TEXT
TX active: true
TX name: com.bank.service.TransferService.transfer
TX readOnly: false

What are the key attributes of @Transactional?

The @Transactional annotation accepts several attributes that control transaction behavior.

| Attribute | Default | Purpose |
|-----------|---------|---------|
| propagation | REQUIRED | Join existing TX or create new |
| isolation | DEFAULT (database default) | Controls what concurrent changes are visible |
| readOnly | false | Hint to skip dirty checking and route to replica |
| timeout | -1 (no timeout) | Fail if TX exceeds N seconds |
| rollbackFor | RuntimeException, Error | Which exception types trigger rollback |
| noRollbackFor | (none) | Which exception types should NOT trigger rollback |

JAVA
package com.bank.service;

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

@Service
public class AccountService {

    private final AccountRepository accountRepo;

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

    // Read-only with 5 second timeout
    @Transactional(readOnly = true, timeout = 5)
    public BigDecimal getBalance(String accountId) {
        return accountRepo.findById(accountId).getBalance();
    }

    // Writable with rollbackFor covering all exceptions
    @Transactional(rollbackFor = Exception.class, timeout = 10)
    public void transfer(String fromId, String toId, BigDecimal amount) {
        Account sender = accountRepo.findByIdForUpdate(fromId);
        Account receiver = accountRepo.findByIdForUpdate(toId);
        sender.debit(amount);
        receiver.credit(amount);
        accountRepo.save(sender);
        accountRepo.save(receiver);
    }

    // Repeatable read for consistent multi-query report
    @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
    public List<Account> generatePortfolioReport(String customerId) {
        return accountRepo.findByCustomerId(customerId);
    }
}

What happens with checked vs unchecked exceptions?

By default, @Transactional rolls back only on unchecked exceptions (RuntimeException and its subclasses, plus Error). If a method throws a checked exception, the proxy commits the transaction. This catches many developers off guard.

In banking, this default is dangerous. If a transfer method throws a checked InsufficientBalanceException, the debit might be committed while the credit never happens. The fix is to add rollbackFor = Exception.class to cover all exception types.

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 AccountRepository accountRepo;
    private final LoanRepository loanRepo;

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

    // WRONG: checked exception causes commit, not rollback
    @Transactional
    public void disburseLoanBad(String loanId) throws LoanApprovalException {
        Loan loan = loanRepo.findById(loanId);
        Account account = accountRepo.findById(loan.getAccountId());
        account.credit(loan.getAmount());
        accountRepo.save(account);
        // If this throws a CHECKED exception, credit is COMMITTED
        externalApprovalService.approve(loanId);
    }

    // CORRECT: rollbackFor covers checked exceptions
    @Transactional(rollbackFor = Exception.class)
    public void disburseLoanCorrect(String loanId) throws LoanApprovalException {
        Loan loan = loanRepo.findById(loanId);
        Account account = accountRepo.findById(loan.getAccountId());
        account.credit(loan.getAmount());
        accountRepo.save(account);
        // If this throws a CHECKED exception, credit is ROLLED BACK
        externalApprovalService.approve(loanId);
    }
}

What is the self-invocation problem?

The self-invocation problem occurs when a method inside a bean calls another @Transactional method on the same bean using this. Because the call goes directly to the target object and not through the AOP proxy, the annotation on the called method is ignored. No transaction is started.

JAVA
package com.bank.service;

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

@Service
public class BatchTransferService {

    private final AccountRepository accountRepo;

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

    // WRONG: calling this.processSingle() bypasses the proxy
    public void processBatch(List<TransferRequest> requests) {
        for (TransferRequest req : requests) {
            this.processSingle(req); // self-invocation, @Transactional ignored
        }
    }

    @Transactional
    public void processSingle(TransferRequest req) {
        Account sender = accountRepo.findByIdForUpdate(req.getFromId());
        Account receiver = accountRepo.findByIdForUpdate(req.getToId());
        sender.debit(req.getAmount());
        receiver.credit(req.getAmount());
    }
}

The fix is to either inject the bean into itself (so the call goes through the proxy), extract the method into a separate bean, or use TransactionTemplate for programmatic control.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;

// CORRECT: extract single-transfer logic into a separate bean
@Service
public class BatchTransferService {

    private final SingleTransferService singleTransferService;

    public BatchTransferService(SingleTransferService singleTransferService) {
        this.singleTransferService = singleTransferService;
    }

    public void processBatch(List<TransferRequest> requests) {
        for (TransferRequest req : requests) {
            singleTransferService.processSingle(req); // goes through proxy
        }
    }
}

@Service
public class SingleTransferService {

    private final AccountRepository accountRepo;

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

    @Transactional
    public void processSingle(TransferRequest req) {
        Account sender = accountRepo.findByIdForUpdate(req.getFromId());
        Account receiver = accountRepo.findByIdForUpdate(req.getToId());
        sender.debit(req.getAmount());
        receiver.credit(req.getAmount());
    }
}

How is @Transactional different from manual JDBC transactions?

Manual JDBC requires explicit connection management. @Transactional replaces all of it with one annotation.

| Aspect | Manual JDBC | @Transactional |
|--------|-------------|----------------|
| Connection management | Manual get/close | Automatic via HikariCP |
| Begin transaction | setAutoCommit(false) | Proxy handles it |
| Commit | Explicit commit() call | Proxy commits on normal return |
| Rollback | Explicit rollback() in catch | Proxy rolls back on RuntimeException |
| Connection sharing | Pass connection as parameter | ThreadLocal binding |
| Propagation | Not supported | REQUIRED, REQUIRES_NEW, etc. |
| Timeout | Manual timer | Built-in timeout attribute |
| Lines of code | 15+ per operation | 1 annotation |

JAVA
package com.bank.service;

import javax.sql.DataSource;
import java.math.BigDecimal;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;

// Manual JDBC: 30+ lines of boilerplate
public class ManualTransferService {

    private final DataSource dataSource;

    public ManualTransferService(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public void transfer(String fromId, String toId, BigDecimal amount)
            throws SQLException {
        Connection conn = null;
        try {
            conn = dataSource.getConnection();
            conn.setAutoCommit(false);

            PreparedStatement debit = conn.prepareStatement(
                "UPDATE account SET balance = balance - ? WHERE id = ?");
            debit.setBigDecimal(1, amount);
            debit.setString(2, fromId);
            debit.executeUpdate();

            PreparedStatement credit = conn.prepareStatement(
                "UPDATE account SET balance = balance + ? WHERE id = ?");
            credit.setBigDecimal(1, amount);
            credit.setString(2, toId);
            credit.executeUpdate();

            conn.commit();
        } catch (SQLException e) {
            if (conn != null) conn.rollback();
            throw e;
        } finally {
            if (conn != null) {
                conn.setAutoCommit(true);
                conn.close();
            }
        }
    }
}

What are common mistakes with @Transactional?

The most frequent mistakes involve the proxy limitations and exception handling rules.

Mistake 1: self-invocation

JAVA
// WRONG: @Transactional on processOne is ignored because of self-invocation
@Service
public class SettlementService {

    public void settleAll() {
        for (Trade trade : findPending()) {
            this.processOne(trade); // bypasses proxy
        }
    }

    @Transactional
    public void processOne(Trade trade) {
        // no transaction is started
    }
}

Mistake 2: private method

JAVA
// WRONG: CGLIB cannot override private methods
@Service
public class FeeService {

    @Transactional
    private void calculateFee(String accountId) {
        // annotation is silently ignored
    }
}
JAVA
// CORRECT: method must be public
@Service
public class FeeService {

    @Transactional
    public void calculateFee(String accountId) {
        // proxy intercepts this call
    }
}

Mistake 3: swallowing the exception

JAVA
// WRONG: catching the exception prevents the proxy from seeing it
@Service
public class PaymentService {

    @Transactional
    public void processPayment(String accountId, BigDecimal amount) {
        try {
            accountRepo.debit(accountId, amount);
            externalGateway.charge(amount);
        } catch (Exception e) {
            log.error("Payment failed", e);
            // transaction commits because exception is swallowed
        }
    }
}
JAVA
// CORRECT: let the exception propagate for rollback
@Service
public class PaymentService {

    @Transactional(rollbackFor = Exception.class)
    public void processPayment(String accountId, BigDecimal amount) {
        accountRepo.debit(accountId, amount);
        externalGateway.charge(amount);
        // if charge fails, exception propagates, proxy rolls back
    }
}

What should a senior engineer know?

A senior engineer understands the proxy internals, connection pool implications, and production tuning.

  • The proxy is literally the manual try-commit-catch-rollback-finally block generated for you by Spring. Knowing this makes debugging transaction issues straightforward.
  • The connection is held open for the entire duration of the transactional method. A slow method (calling an external API inside a transaction) starves the HikariCP pool. Move non-database work outside the transaction boundary.
  • The timeout attribute sets Statement.setQueryTimeout(), not a wall-clock limit. A long-running network call does not trigger it.
  • TransactionSynchronizationManager uses ThreadLocal, so @Transactional does not work across threads. If you spawn a new thread inside a transactional method, the new thread has no transaction.
  • Spring Boot 3.x defaults to CGLIB proxies. The class must not be final, and transactional methods must not be final or private.
JAVA
package com.bank.config;

import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class TransactionConfig {

    // Show SQL and transaction boundaries in logs for debugging
    @Bean
    public HibernatePropertiesCustomizer hibernateCustomizer() {
        return properties -> {
            properties.put("hibernate.show_sql", "true");
            properties.put("hibernate.format_sql", "true");
        };
    }
}

Enable transaction logging in application.yml:

YAML
logging:
  level:
    org.springframework.transaction: TRACE
    org.springframework.orm.jpa: DEBUG
TEXT
TRACE o.s.t.i.TransactionInterceptor - Getting transaction for [com.bank.service.TransferService.transfer]
DEBUG o.s.orm.jpa.JpaTransactionManager - Creating new transaction with name [com.bank.service.TransferService.transfer]
DEBUG o.s.orm.jpa.JpaTransactionManager - Initiating transaction commit
TRACE o.s.t.i.TransactionInterceptor - Completing transaction for [com.bank.service.TransferService.transfer]

What are common interview follow-up questions?

Interviewers often follow up with these questions after the main explanation:

  • What happens if you call a @Transactional method from within the same class?
  • What exception types trigger rollback by default?
  • How do you make checked exceptions trigger rollback?
  • Can @Transactional be applied to a private method?
  • What happens if the transactional method spawns a new thread?
  • How do you debug when a transaction is not starting?
  • What is the difference between @Transactional on the class vs the method?

The answers to all of these are covered in the sub-questions above. The self-invocation problem and the checked-exception commit trap are the two most common follow-ups in banking interviews.

Visual Overview

DIAGRAM
  External Caller
       │
       ▼
  ┌──────────────────────────────────────────┐
  │         CGLIB Proxy (Spring AOP)          │
  │                                            │
  │  1. Get Connection from HikariCP           │
  │  2. setAutoCommit(false)                   │
  │  3. Bind connection to ThreadLocal         │
  │  4. Call real method ─────────┐            │
  │                               │            │
  │  ┌────────────────────────────┼──────┐    │
  │  │    Real Bean Method         │      │    │
  │  │    (TransferService)        │      │    │
  │  │                             ▼      │    │
  │  │  repo.save(sender)   ← same conn  │    │
  │  │  repo.save(receiver) ← same conn  │    │
  │  └────────────────────────────────────┘    │
  │                                            │
  │  5a. Normal return → commit()              │
  │  5b. RuntimeException → rollback()         │
  │  6. Release connection to pool             │
  └──────────────────────────────────────────┘

Key Takeaway

The @Transactional annotation works through an AOP proxy that Spring creates around the bean. When a transactional method is called from outside the class, the proxy intercepts the call, obtains a database connection from the HikariCP pool, disables auto-commit to begin a transaction, and binds the connection to the current thread via TransactionSynchronizationManager. All repository calls within that method share the same connection through ThreadLocal lookup, ensuring they participate in the same transaction. On successful completion the proxy commits. On RuntimeException or Error the proxy rolls back. Checked exceptions do not trigger rollback by default, which is a dangerous trap in banking where you must add rollbackFor equals Exception.class.

In Rajesh's NEFT transfer at National Bank of India, the transfer method debits account SAV2001 and credits Priya's account SAV3002 within a single transaction. If the credit fails because Priya's account is frozen, the proxy catches the RuntimeException and rolls back the entire transaction including the debit. Rajesh's balance stays unchanged because atomicity is preserved. The method must be public (CGLIB cannot override private methods), must be called from outside the class (self-invocation bypasses the proxy), and uses SELECT FOR UPDATE to prevent concurrent modification of the same account row.

The key attributes let you tune the transaction: propagation controls how nested calls behave (REQUIRED joins the existing transaction or creates a new one), isolation controls what concurrent transactions can see (READ_COMMITTED is the PostgreSQL default), readOnly skips Hibernate dirty checking and hints the driver to route to a read replica, timeout fails the transaction after N seconds to prevent connection pool starvation, and rollbackFor specifies which exception types trigger rollback beyond the default RuntimeException.

Remember one sentence: @Transactional is an AOP proxy that gets a connection, sets auto-commit to false, calls your method, commits on success, rolls back on RuntimeException, and only works on public methods called from outside the bean.

---

Share
Use ← → arrow keys to navigate