💳 Section 11 · Question #16

What is the Transactional Annotation

Annotating a method with @Transactional instructs Spring Framework to: 4. If the method throws an unhandled RuntimeException or Error, automatically execute a ROLLBACK.


🟢 Junior Level

@Transactional is Spring Framework’s premier annotation for declarative transaction management. It relieves developers from writing manual transaction boilerplate (connection.setAutoCommit(false), commit(), rollback()) and managing try-finally cleanup blocks.

The Core Concept in 30 Seconds

Annotating a method with @Transactional instructs Spring Framework to:

  1. Automatically open a database transaction before the method begins executing.
  2. Execute the business logic inside the method.
  3. If the method finishes cleanly without unhandled errors, execute a COMMIT.
  4. If the method throws an unhandled RuntimeException or Error, automatically execute a ROLLBACK.
[ Client Call ]
      │
      ▼
[ Spring AOP Proxy ] ──────────► BEGIN TRANSACTION (checks out connection from pool)
      │
      ▼
[ Service Method Call ] ───────► Executes SQL statements against DB
      │
      ├── Without Errors? ─────► COMMIT (modifications permanently persisted)
      │
      └── RuntimeException? ───► ROLLBACK (all modifications cleanly undone)

Practical Spring Boot Example: Bank Transfer

@Service
public class BankTransferService {

    private final AccountRepository accountRepository;

    public BankTransferService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    @Transactional // Guarantees atomic money transfer
    public void transferMoney(Long fromId, Long toId, BigDecimal amount) {
        Account from = accountRepository.findById(fromId)
                .orElseThrow(() -> new IllegalArgumentException("Sender account not found"));
        Account to = accountRepository.findById(toId)
                .orElseThrow(() -> new IllegalArgumentException("Recipient account not found"));

        from.debit(amount);
        to.credit(amount);

        accountRepository.save(from);
        // If an unexpected crash or exception occurs here,
        // the debit from 'from' is guaranteed to roll back cleanly!
        accountRepository.save(to);
    }
}

Core Attributes of @Transactional

Attribute Purpose Default Value
propagation Defines boundary behavior when interacting with other transactions Propagation.REQUIRED
isolation Configures database transaction isolation level Isolation.DEFAULT (Database engine default)
readOnly Performance optimization flag for read-only query streams false
timeout Maximum allowed execution duration in seconds -1 (No timeout enforced)
rollbackFor Exception classes that trigger transaction rollback RuntimeException.class, Error.class
noRollbackFor Exception classes that must NOT trigger rollback {} (Empty array)

🟡 Middle Level

Architectural Mechanism: Spring AOP Proxy

Spring manages transactions using the AOP Dynamic Proxy design pattern:

Application Context Startup:
Target Bean (BankTransferService) ──► Wrapped in CGLIB / ByteBuddy Proxy
                                                │
At Runtime Invocations:                         ▼
proxy.transferMoney() ──────────────► [ TransactionInterceptor ]
                                                │
                                1. Inspects @Transactional metadata
                                2. Queries PlatformTransactionManager
                                3. Inspects ThreadLocal resources
                                4. Executes target method on real bean
                                5. Intercepts outcome: Commit or Rollback

Placement Rules: Method vs. Class vs. Interface

  1. On Method: Most granular and recommended approach. Overrides any class-level defaults for the specific method.
  2. On Class: Applies transaction defaults to all public methods of that class.
  3. On Interface: ⚠️ Dangerous Anti-Pattern! Works only if legacy JDK Dynamic Proxies are used. If Spring Boot uses its standard CGLIB subclass proxying (spring.aop.proxy-target-class=true), annotations on interfaces are completely ignored, and no transactions will be created!

Common Developer Mistakes and Anti-Patterns

Anti-Pattern / Bug Consequence Correct Architectural Solution
Self-invocation via this.method() Bypasses the AOP Proxy; transaction is not opened Extract method to a separate @Service bean or use self-injection
Annotation on private method Proxy cannot override private methods; silently ignored Mark method as public
Annotation on final method CGLIB subclass cannot override final methods; ignored Remove final modifier from transactional methods
Checked Exception without rollbackFor Thrown IOException or SQLException results in COMMIT Always declare @Transactional(rollbackFor = Exception.class)
Swallowed exception in try-catch Hiding the exception causes Spring to execute a COMMIT Re-throw exception or call TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()
@Transactional on @RestController Holds DB connection open during JSON serialization and slow I/O Place @Transactional strictly within the @Service layer

🔴 Senior Level

Transaction Engine Internals: TransactionInterceptor and TransactionAspectSupport

In Spring’s transaction core, execution interception is handled inside TransactionAspectSupport.invokeWithinTransaction:

// Simplified architecture of Spring Framework's invokeWithinTransaction:
protected Object invokeWithinTransaction(Method method, Class<?> targetClass,
                                          InvocationCallback invocation) throws Throwable {

    TransactionAttributeSource tas = getTransactionAttributeSource();
    TransactionAttribute txAttr = (tas != null ? tas.getTransactionAttribute(method, targetClass) : null);
    PlatformTransactionManager tm = determineTransactionManager(txAttr);

    // 1. Create transaction if required by propagation:
    TransactionInfo txInfo = createTransactionIfNecessary(tm, txAttr, joinpointIdentification);

    Object retVal;
    try {
        // 2. Invoke the target business method on the real bean:
        retVal = invocation.proceedWithInvocation();
    } catch (Throwable ex) {
        // 3. Exception caught: evaluate rollbackFor / noRollbackFor rules:
        completeTransactionAfterThrowing(txInfo, ex);
        throw ex;
    } finally {
        // 4. Clean up ThreadLocal state:
        cleanupTransactionInfo(txInfo);
    }

    // 5. Commit on clean return:
    commitTransactionAfterReturning(txInfo);
    return retVal;
}

CGLIB vs. JDK Dynamic Proxy: Bytecode Generation

Spring Boot 2.x and 3.x use CGLIB proxying via ByteBuddy by default (spring.aop.proxy-target-class = true):

  • JDK Dynamic Proxies: Generates proxy classes using java.lang.reflect.Proxy. Requires target beans to implement interfaces.
  • CGLIB Proxies: Generates a synthetic subclass at runtime (BankTransferService$$SpringCGLIB$$0 extends BankTransferService).
    • Does not require interfaces.
    • Limitation with final: Because the Java Virtual Machine strictly prohibits overriding final methods in subclasses, CGLIB cannot weave its interception hook into final methods. Invoking a final method executes the base method directly, bypassing TransactionInterceptor.

Database Connection Lifecycle: Lazy Connection Acquisition

Many developers assume @Transactional immediately checks out a physical connection from HikariCP upon entering the method:

  1. By default with Spring Data JPA and Hibernate, connection checkout is lazy (Lazy Connection Acquisition): a physical socket connection is requested from HikariCP only when the application executes its first SQL statement (Statement.execute()) or accesses an ID sequence generator.
  2. The Isolation Level Exception: If @Transactional(isolation = Isolation.REPEATABLE_READ) or another non-default isolation level is specified, Spring must check out the connection immediately at the method entry point to execute connection.setTransactionIsolation(...).

Programmatic Alternative: TransactionTemplate

In high-concurrency microservices, keeping transactions as short as possible is critical. Senior engineers frequently replace declarative @Transactional with TransactionTemplate to avoid holding database connections during external RPC or REST calls:

@Service
public class HighloadOrderService {

    private final TransactionTemplate transactionTemplate;
    private final OrderRepository orderRepository;
    private final ExternalPaymentClient paymentClient;

    public HighloadOrderService(TransactionTemplate transactionTemplate,
                                OrderRepository orderRepository,
                                ExternalPaymentClient paymentClient) {
        this.transactionTemplate = transactionTemplate;
        this.orderRepository = orderRepository;
        this.paymentClient = paymentClient;
    }

    public void processOrderWithExternalPayment(Long orderId) {
        // Step 1: Heavy external network call (ZERO database connections held!)
        PaymentResponse response = paymentClient.executePayment(orderId);

        // Step 2: Minimalist database transaction scoped strictly to row update:
        transactionTemplate.execute(status -> {
            orderRepository.updateStatus(orderId, response.getStatus());
            return true;
        });
    }
}

4 Tricky Questions

1. What happens if a method annotated with @Transactional is marked with the final keyword under standard Spring Boot CGLIB proxying?

Answer: The application compiles and boots without errors, but no transaction will ever be opened! Under Spring Boot’s default CGLIB proxying mechanism, Spring dynamically generates a subclass of the target bean. According to JVM specification, final methods cannot be overridden by subclasses. Consequently, CGLIB cannot inject its interceptor logic. When called from an external class, the JVM dispatches execution directly to the original target class implementation, completely bypassing TransactionInterceptor. The code runs in auto-commit mode or throws TransactionRequiredException.


2. Why does Spring’s @Transactional by default roll back on RuntimeException and Error, but commit on checked Exception?

Answer: This behavior originates from the architectural philosophy established by Rod Johnson, reflecting standard Java exception design:

  • RuntimeException (Unchecked): Represents an unexpected programming fault, hardware failure, or infrastructure corruption (e.g., NullPointerException, DataAccessException). The system state cannot be trusted, so rolling back is the only safe default.
  • Checked Exception: Represents a predictable, recoverable business alternative (e.g., InsufficientFundsException, CustomerNotFoundException). The compiler forces the caller to catch and handle the scenario, and Spring assumes the caller may want to commit partial progress or handle the situation gracefully. To enforce rollbacks on all exceptions, developers must explicitly specify @Transactional(rollbackFor = Exception.class).

3. At what precise moment is a physical connection checked out from HikariCP when entering a @Transactional method?

Answer: It depends on the transaction configuration:

  1. Under Isolation.DEFAULT: The connection checkout is lazy. Spring defers checking out a physical JDBC connection from HikariCP until the first actual SQL query is issued by Hibernate/JPA.
  2. Under custom isolation (e.g., Isolation.REPEATABLE_READ, SERIALIZABLE): Spring must immediately check out a physical connection at the AOP proxy boundary in order to invoke connection.setTransactionIsolation(...) before executing method instructions.

4. Why is placing @Transactional on a @RestController method considered a catastrophic architectural mistake in Highload systems?

Answer: It leads directly to Connection Pool Exhaustion:

  1. When @Transactional wraps a controller endpoint, the database connection is held open throughout the entire HTTP request lifecycle, including request body parsing, controller execution, and response serialization.
  2. If clients download responses over slow network connections (e.g., mobile networks) or if the controller serializes large JSON payloads, the JDBC connection remains checked out and idle for hundreds of milliseconds or seconds.
  3. As few as 20–50 concurrent slow clients can monopolize the entire HikariCP pool, starving the rest of the application and causing widespread ConnectionTimeoutException failures across all microservice instances.

🎯 Interview Cheat Sheet

  • Core Definition: Spring’s declarative transaction demarcation annotation, implemented via AOP Dynamic Proxies (TransactionInterceptor).
  • Rollback Rule: By default, rolls back strictly on RuntimeException and Error. Checked exceptions commit unless configured with rollbackFor = Exception.class.
  • Public Methods Only: AOP proxies only intercept public methods invoked from external beans.
  • Self-Invocation Failure: Invoking @Transactional methods via this.method() bypasses the proxy and disables transaction management.
  • CGLIB vs. Final: CGLIB subclasses cannot override final methods; declaring a @Transactional method as final disables transactions silently.
  • Placement Guideline: Place @Transactional exclusively on the @Service layer; never on @RestController.
  • Lazy Checkout: JDBC connections are acquired lazily upon the first SQL query, unless a custom isolation level is defined.