🍃 Section 5 · Question #17

What does the Transactional annotation do

In enterprise applications, operations spanning multiple database tables must adhere to the ACID properties (Atomicity, Consistency, Isolation, Durability). If any step within a...


🟢 Junior Level

@Transactional provides declarative transaction management in Spring applications. It delegates the responsibility of opening, committing, and rolling back database transactions to the Spring IoC container, eliminating error-prone manual boilerplate code (beginTransaction(), commit(), rollback()).

Why Use @Transactional

In enterprise applications, operations spanning multiple database tables must adhere to the ACID properties (Atomicity, Consistency, Isolation, Durability). If any step within a business operation fails, all database modifications performed by that method must be completely undone (rolled back) to prevent data corruption.

package com.example.service;

import com.example.model.Order;
import com.example.repository.OrderRepository;
import com.example.repository.PaymentRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final PaymentRepository paymentRepository;

    public OrderService(OrderRepository orderRepository, PaymentRepository paymentRepository) {
        this.orderRepository = orderRepository;
        this.paymentRepository = paymentRepository;
    }

    @Transactional
    public void placeOrder(Order order, Long amount) {
        // Step 1: Insert order record into DB
        orderRepository.save(order);

        // Step 2: Deduct funds from user balance
        paymentRepository.charge(order.getUserId(), amount);

        // If an unhandled RuntimeException occurs, both operations roll back atomically!
    }
}

How It Works Under the Hood (The AOP Lifecycle)

Client Invocation
       │
       ▼
[Dynamic Proxy Wrapper]
       │
       ├──> 1. Intercepted by TransactionInterceptor
       ├──> 2. Requests JDBC Connection from PlatformTransactionManager (HikariCP)
       ├──> 3. Executes connection.setAutoCommit(false)
       │
       ▼
[Target Bean Business Method] (Inserts / Updates executed on the shared connection)
       │
       ▼
[Dynamic Proxy Wrapper Intercepts Return]
       │
       ├──> Method returned cleanly  ──> Executes connection.commit()
       └──> Method threw Exception   ──> Executes connection.rollback()
       │
       ▼
Connection returned to HikariCP Pool

🟡 Middle Level

Parameters of the @Transactional Annotation

Spring allows fine-grained customization of transaction behavior:

@Transactional(
    propagation = Propagation.REQUIRED,
    isolation = Isolation.READ_COMMITTED,
    timeout = 10,
    readOnly = false,
    rollbackFor = {Exception.class},
    noRollbackFor = {NonCriticalBusinessException.class}
)

Transaction Propagation Behaviors

Propagation determines how transaction boundaries behave when a transactional method calls another transactional method:

Propagation Strategy Behavior When Transaction Already Exists Behavior When No Transaction Exists
REQUIRED (Default) Joins the existing physical transaction Creates a brand-new physical transaction
REQUIRES_NEW Suspends existing transaction; opens an independent new transaction Creates a brand-new transaction
NESTED Creates a JDBC Savepoint within the existing transaction Creates a brand-new transaction
MANDATORY Joins the existing transaction Throws NoTransactionException
SUPPORTS Joins the existing transaction Executes non-transactionally
NOT_SUPPORTED Suspends existing transaction; executes non-transactionally Executes non-transactionally
NEVER Throws IllegalTransactionStateException Executes non-transactionally

Transaction Isolation Levels

Isolation levels control data visibility between concurrent database transactions:

  1. DEFAULT: Uses the default isolation level of the underlying RDBMS (PostgreSQL defaults to READ_COMMITTED; MySQL InnoDB defaults to REPEATABLE_READ).
  2. READ_COMMITTED: Prevents Dirty Reads (reading uncommitted data from other transactions).
  3. REPEATABLE_READ: Prevents Dirty Reads and Non-Repeatable Reads (re-reading the same row returns identical data throughout the transaction).
  4. SERIALIZABLE: Complete physical isolation; prevents Dirty Reads, Non-Repeatable Reads, and Phantom Reads via strict range locking or multi-version serialization checks.

The Checked Exception Rollback Trap

[!WARNING] Spring Default Rollback Invariant: By default, Spring ONLY triggers rollbacks on Unchecked Exceptions (RuntimeException and Error).

If a method throws a Checked Exception (e.g., IOException, SQLException, ParseException), Spring will commit the transaction normally, persisting potentially inconsistent or corrupt data!

// ❌ DANGEROUS DEFECT: Checked exception will commit corrupt database changes!
@Transactional
public void processBatchFile(File file) throws IOException {
    orderRepository.save(new Order());
    if (!file.exists()) {
        throw new IOException("File not found on storage volume!"); // COMMITS ANYWAY!
    }
}

// ✅ PRODUCTION STANDARD: Explicitly declare rollback for all Exceptions:
@Transactional(rollbackFor = Exception.class)
public void processBatchFileSafe(File file) throws IOException {
    orderRepository.save(new Order());
    if (!file.exists()) {
        throw new IOException("File not found on storage volume!"); // ROLLS BACK PROPERLY!
    }
}

🔴 Senior Level

Resource Binding: TransactionSynchronizationManager

Spring achieves transaction coordination across multiple repositories and components without passing the Connection object through method parameters:

  1. When a transaction begins, PlatformTransactionManager obtains a physical JDBC Connection from the DataSource.
  2. It binds this connection to the current thread using TransactionSynchronizationManager.bindResource(dataSource, connectionHolder).
  3. Internally, TransactionSynchronizationManager stores resources in a ThreadLocal<Map<Object, Object>>.
  4. When JdbcTemplate or Hibernate SessionFactory executes a query, it calls DataSourceUtils.getConnection(dataSource), retrieving the active connection already bound to the calling thread.
  5. Upon commit or rollback, the resources are unbound and the connection is returned to the pool.

The readOnly = true Deep Optimization

Setting @Transactional(readOnly = true) is an optimization hint that operates across two distinct layers:

  1. Hibernate / JPA Layer:
    • Hibernate sets the Session flush mode to FlushMode.MANUAL.
    • Dirty Checking is completely disabled: Hibernate skips entity snapshot allocation and eliminates the end-of-transaction memory comparison loop. In large batch queries loading 10,000+ entities, this drastically reduces memory usage and saves substantial CPU time.
  2. JDBC / RDBMS Layer:
    • Spring calls connection.setReadOnly(true) on the physical connection.
    • Database routing infrastructure (e.g. AbstractRoutingDataSource) uses this flag to route read queries directly to Read Replicas, offloading the Primary master node.

[!NOTE] readOnly = true is primarily an optimization hint. It does not strictly guarantee at the database syntax level that an INSERT statement cannot be executed unless the database driver or replica node strictly enforces read-only mode.

The REQUIRES_NEW Connection Pool Deadlock Trap

A classic production catastrophe under heavy concurrent load occurs when nesting REQUIRES_NEW within an existing transaction:

@Service
public class OrderService {

    @Autowired
    private AuditService auditService;

    @Transactional // Outer Transaction: Acquires Connection #1 from HikariCP
    public void placeOrder(Order order) {
        orderRepository.save(order);
        auditService.logAuditRecord("ORDER_CREATED"); // Calls REQUIRES_NEW
    }
}

@Service
public class AuditService {

    @Transactional(propagation = Propagation.REQUIRES_NEW) // Inner Transaction: Requires Connection #2
    public void logAuditRecord(String action) {
        auditRepository.save(new AuditLog(action));
    }
}

Deadlock Execution Mechanics:

  1. HikariCP pool size is configured to 10 connections (maximum-pool-size = 10).
  2. A sudden traffic spike hits: exactly 10 concurrent HTTP requests invoke placeOrder().
  3. All 10 worker threads acquire Connection #1 from HikariCP (all 10 connections are exhausted).
  4. Each thread reaches auditService.logAuditRecord().
  5. Because REQUIRES_NEW requires an independent transaction, each thread requests a second connection from HikariCP while holding onto its first connection.
  6. The pool is empty. All 10 threads block waiting for a connection to be released.
  7. No thread can ever finish its outer transaction to release its first connection.
  8. Result: An application-wide Connection Pool Deadlock. Every request times out after connection-timeout (typically 30 seconds), causing cascading 500 errors.

The Long-Running Transaction Anti-Pattern

@Transactional
public void processCheckout(Order order) {
    orderRepository.save(order);              // 1. Holds DB Connection (5ms)
    paymentGatewayClient.chargeCard(order);   // 2. ⚠️ REMOTE HTTP CALL (takes 2,500ms!)
    auditRepository.logSuccess(order);        // 3. Holds DB Connection (5ms)
}

Architecture Invariant: Never perform slow external HTTP, gRPC, or file I/O calls inside a @Transactional block. Holding an idle database connection while waiting on network I/O starves the connection pool and collapses application throughput.

Transactional Messaging: @TransactionalEventListener

Emitting events directly to Apache Kafka or RabbitMQ inside @Transactional causes Phantom Events: if the message broker receives the event but the database subsequently rolls back during commit, downstream consumers will process events for records that do not exist!

Solution: Use @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) or the Transactional Outbox Pattern:

package com.example.listener;

import com.example.event.OrderCreatedEvent;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Component
public class OrderNotificationListener {

    // Guaranteed to execute ONLY after the database transaction has committed cleanly!
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onOrderCommitted(OrderCreatedEvent event) {
        kafkaProducer.send("orders-topic", event);
    }
}

4 Tricky Questions

1. Will a transaction roll back if an IOException is thrown from a method annotated with @Transactional? How do you guarantee rollback for all exceptions?

Answer: No, the transaction will not roll back. By default, Spring’s declarative transaction manager only rolls back on Unchecked Exceptions (subclasses of RuntimeException) and Error. Checked exceptions (like IOException, SQLException, ClassNotFoundException) represent recoverable business conditions under EJB conventions, so Spring commits the transaction despite the exception.

To guarantee rollback on all exceptions (both checked and unchecked), you must explicitly configure:

@Transactional(rollbackFor = Exception.class)

2. What is the fundamental difference between Propagation.REQUIRES_NEW and Propagation.NESTED at the database connection level?

Answer:

  • REQUIRES_NEW: Suspends the outer transaction and opens a completely independent physical transaction requiring a second physical JDBC Connection from the connection pool. It has its own commit/rollback cycle. A failure in the outer transaction will not roll back the inner transaction once committed.
  • NESTED: Uses the exact same physical JDBC Connection as the outer transaction. It creates a database Savepoint (via connection.setSavepoint()). If the nested method fails, it rolls back only to the savepoint, allowing the outer transaction to catch the exception and proceed to commit. Nested requires JDBC 3.0+ driver support and an active outer transaction.

3. How can Propagation.REQUIRES_NEW trigger a complete application-wide connection pool deadlock in production under high traffic?

Answer: Because REQUIRES_NEW requires an independent connection, each active thread concurrently running an outer @Transactional method and calling an inner @Transactional(REQUIRES_NEW) method holds two database connections simultaneously.

If the number of concurrent incoming requests equals or exceeds the total size of the connection pool (e.g. 10 concurrent requests with a HikariCP pool of 10), all 10 connections are grabbed by the outer transactions. When all 10 threads attempt to acquire their second connection for the inner transaction, the pool is empty. All threads block indefinitely waiting for a connection, causing an unrecoverable connection pool deadlock.

4. What technical optimizations occur inside Hibernate and the JDBC driver when readOnly = true is enabled?

Answer:

  1. Hibernate Optimization: Hibernate switches its Session flush mode to FlushMode.MANUAL. It skips tracking entity snapshots in the Persistence Context, completely disabling dirty-checking loops. At transaction completion, Hibernate does not scan managed entities for state changes, yielding significant memory and CPU savings for read-heavy operations.
  2. JDBC Driver Optimization: Spring calls connection.setReadOnly(true) on the physical JDBC connection. The driver and connection pool (such as AWS Aurora or dynamic multi-datasource routers) can use this hint to route the connection directly to read-only replica nodes, preventing read query load on the primary write database.

🎯 Interview Cheat Sheet

30-Second Elevator Pitch

@Transactional provides declarative transaction management via Spring AOP proxies and PlatformTransactionManager.

  • Wraps business methods in an interceptor chain that manages connection acquisition, sets autoCommit(false), and triggers commit() on success or rollback() on failure.
  • Default Rollback: Triggers only for RuntimeException and Error. Checked exceptions (IOException, etc.) require explicit rollbackFor = Exception.class.
  • Key Parameters: propagation (REQUIRED default, REQUIRES_NEW, NESTED), isolation (concurrency levels), readOnly (disables Hibernate dirty checking), timeout.
  • Production Watchouts: Self-invocation bypasses transactional proxies; REQUIRES_NEW can trigger connection pool deadlocks; slow HTTP calls inside transactions exhaust connection pools.

Propagation Modes Comparison Table

Mode Active Tx Exists? Action Taken Physical Connections Used
REQUIRED Yes Joins active transaction 1
REQUIRED No Creates new transaction 1
REQUIRES_NEW Yes Suspends active; creates independent new transaction 2 (Deadlock Risk)
NESTED Yes Creates Savepoint within existing transaction 1
MANDATORY No Throws Exception 0
NEVER Yes Throws Exception 0

Red Flags (DO NOT Say)

  • ❌ “Spring automatically rolls back transactions for all exceptions by default.” (Checked exceptions commit by default; only RuntimeException and Error trigger rollback).
  • ❌ “Setting readOnly = true physically prevents any INSERT or UPDATE statements from being run by the database.” (It is an optimization hint to Hibernate and drivers, not an infallible database security guarantee).
  • ❌ “REQUIRES_NEW and NESTED are identical.” (REQUIRES_NEW uses a separate connection; NESTED uses JDBC Savepoints on the same connection).
  • ❌ “It is fine to make external REST API calls inside @Transactional.” (Holding DB connections across slow network calls exhausts connection pools).