Full Stack • Java • System Design • Cloud • AI Engineering

Frameworks2024-02-04

Spring Transaction Management

Master Spring Transaction Management with ACID properties, propagation behaviors, isolation levels, real-world examples, and best practices.

Spring Transaction Management

Table of Contents

  1. What is a Transaction?
  2. Transaction Management in Spring
  3. The @Transactional Annotation
  4. Transaction Propagation
  5. Transaction Isolation Levels
  6. Transaction Rollback
  7. Real-World Examples
  8. Best Practices
  9. Common Pitfalls

What is a Transaction?

A transaction is a sequence of operations performed as a single logical unit of work. All operations must succeed together, or all must fail together.

ACID Properties

Every transaction must guarantee four properties:

1. Atomicity - All or Nothing

Transfer $100 from Account A to Account B:
1. Deduct $100 from A
2. Add $100 to B

✅ Both operations succeed → Transaction commits
❌ Any operation fails → Both operations rollback

Example:

// Without atomicity
deductFromAccount(A, 100);  // ✓ Success
// System crashes here!
addToAccount(B, 100);        // ✗ Never executed
// Result: $100 disappeared!

// With atomicity
@Transactional
public void transfer() {
    deductFromAccount(A, 100);
    addToAccount(B, 100);
    // Both succeed or both fail
}

2. Consistency - Valid State to Valid State

Before: A=$500, B=$300, Total=$800
After:  A=$400, B=$400, Total=$800

✅ Total remains consistent
❌ Invalid states are prevented

3. Isolation - Concurrent Transactions Don't Interfere

Transaction 1: Transfer $100 from A to B
Transaction 2: Transfer $50 from A to C

Both run concurrently without seeing each other's intermediate states.

4. Durability - Committed Changes Persist

After commit, changes survive:
✅ System crashes
✅ Power failures
✅ Database restarts

Transaction Management in Spring

Without Transaction Management

@Service
public class BankService {
    
    @Autowired
    private AccountRepository accountRepository;
    
    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        // Step 1: Deduct from source
        Account fromAccount = accountRepository.findById(fromId);
        fromAccount.setBalance(fromAccount.getBalance().subtract(amount));
        accountRepository.save(fromAccount);
        
        // ⚠️ If exception occurs here, money is lost!
        // Source debited but destination not credited
        
        // Step 2: Add to destination
        Account toAccount = accountRepository.findById(toId);
        toAccount.setBalance(toAccount.getBalance().add(amount));
        accountRepository.save(toAccount);
    }
}

Problem Flow:

1. Deduct $100 from Account A ✓
2. Exception occurs! ✗
3. Account B never credited ✗

Result: $100 disappeared from the system!

With Transaction Management

@Service
public class BankService {
    
    @Autowired
    private AccountRepository accountRepository;
    
    @Transactional
    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        // Step 1: Deduct from source
        Account fromAccount = accountRepository.findById(fromId);
        fromAccount.setBalance(fromAccount.getBalance().subtract(amount));
        accountRepository.save(fromAccount);
        
        // If exception occurs, entire transaction rolls back
        
        // Step 2: Add to destination
        Account toAccount = accountRepository.findById(toId);
        toAccount.setBalance(toAccount.getBalance().add(amount));
        accountRepository.save(toAccount);
        
        // Both operations succeed or both fail
    }
}

Transaction Flow:

BEGIN TRANSACTION
    ↓
1. Deduct $100 from Account A
    ↓
2. Exception occurs?
    ├─ Yes → ROLLBACK (restore Account A)
    └─ No → Continue
    ↓
3. Add $100 to Account B
    ↓
4. All operations successful?
    ├─ Yes → COMMIT (make changes permanent)
    └─ No → ROLLBACK (undo all changes)

Transaction Management Types

1. Programmatic Transaction Management

Manual control over transactions - You explicitly manage transaction boundaries.

@Service
public class BankService {
    
    @Autowired
    private PlatformTransactionManager transactionManager;
    
    @Autowired
    private AccountRepository accountRepository;
    
    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        // Define transaction
        TransactionDefinition def = new DefaultTransactionDefinition();
        
        // Start transaction
        TransactionStatus status = transactionManager.getTransaction(def);
        
        try {
            // Business logic
            Account fromAccount = accountRepository.findById(fromId);
            fromAccount.setBalance(fromAccount.getBalance().subtract(amount));
            accountRepository.save(fromAccount);
            
            Account toAccount = accountRepository.findById(toId);
            toAccount.setBalance(toAccount.getBalance().add(amount));
            accountRepository.save(toAccount);
            
            // Commit transaction
            transactionManager.commit(status);
            
        } catch (Exception e) {
            // Rollback transaction
            transactionManager.rollback(status);
            throw e;
        }
    }
}

Pros:

  • ✅ Fine-grained control
  • ✅ Flexible for complex scenarios

Cons:

  • ❌ Verbose and boilerplate code
  • ❌ Error-prone
  • ❌ Mixes business logic with transaction code

2. Declarative Transaction Management (Recommended)

Using @Transactional annotation - Spring manages transactions automatically.

@Service
public class BankService {
    
    @Autowired
    private AccountRepository accountRepository;
    
    @Transactional
    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        // Clean business logic only
        Account fromAccount = accountRepository.findById(fromId);
        fromAccount.setBalance(fromAccount.getBalance().subtract(amount));
        accountRepository.save(fromAccount);
        
        Account toAccount = accountRepository.findById(toId);
        toAccount.setBalance(toAccount.getBalance().add(amount));
        accountRepository.save(toAccount);
    }
}

How It Works (Spring AOP):

Client calls transfer()
        ↓
    AOP Proxy intercepts
        ↓
    Begin Transaction
        ↓
    Execute transfer() method
        ↓
    Exception thrown?
        ├─ Yes → Rollback → Throw exception
        └─ No → Commit → Return result

Pros:

  • ✅ Clean, readable code
  • ✅ Less boilerplate
  • ✅ Easy to maintain
  • Recommended approach

Cons:

  • ❌ Less fine-grained control
  • ❌ Proxy limitations (self-invocation issues)

The @Transactional Annotation

Basic Usage

// Class level - applies to all public methods
@Service
@Transactional
public class UserService {
    
    public void createUser(User user) { }
    public void updateUser(User user) { }
    public void deleteUser(Long id) { }
}

// Method level - applies to specific method
@Service
public class UserService {
    
    @Transactional
    public void createUser(User user) { }
    
    // No transaction
    public User getUser(Long id) { }
}

Configuration Attributes

@Transactional(
    propagation = Propagation.REQUIRED,      // How transactions relate
    isolation = Isolation.DEFAULT,           // Isolation level
    timeout = 30,                            // Timeout in seconds
    readOnly = false,                        // Read-only optimization
    rollbackFor = Exception.class,           // Rollback on these exceptions
    noRollbackFor = BusinessException.class  // Don't rollback on these
)
public void complexOperation() {
    // Transaction configuration applied
}

Transaction Propagation

Definition: How transactions relate to each other when one transactional method calls another.

1. REQUIRED (Default)

Behavior: Use existing transaction or create new one

@Transactional(propagation = Propagation.REQUIRED)
public void methodA() {
    // Transaction T1 starts
    methodB();  // Uses same transaction T1
}

@Transactional(propagation = Propagation.REQUIRED)
public void methodB() {
    // Uses existing transaction T1
}

Flow:

methodA() called
    ↓
Transaction exists?
    ├─ Yes → Use existing transaction
    └─ No → Create new transaction
    ↓
Execute methodA()
    ↓
Call methodB()
    ↓
Use same transaction
    ↓
Both methods in same transaction
    ↓
Commit or Rollback together

Use Case: Default behavior, most common scenario

2. REQUIRES_NEW

Behavior: Always create new transaction, suspend existing

@Transactional(propagation = Propagation.REQUIRED)
public void methodA() {
    // Transaction T1
    methodB();  // Creates new transaction T2, suspends T1
    // T1 resumes after T2 completes
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void methodB() {
    // New transaction T2
    // Independent of T1
}

Flow:

methodA() - Transaction T1
    ↓
Call methodB()
    ↓
Suspend T1
    ↓
Create new Transaction T2
    ↓
Execute methodB()
    ↓
T2 commits/rollbacks independently
    ↓
Resume T1
    ↓
T1 continues
    ↓
T1 commits/rollbacks independently

Use Case: Logging, audit trails (must persist even if main transaction fails)

Example:

@Service
public class OrderService {
    
    @Autowired
    private AuditService auditService;
    
    @Transactional
    public void processOrder(Order order) {
        // Transaction T1
        orderRepository.save(order);
        
        // Log audit - must persist even if order fails
        auditService.logOrderAttempt(order);  // Transaction T2
        
        // If this fails, T1 rolls back but T2 already committed
        paymentService.processPayment(order);
    }
}

@Service
public class AuditService {
    
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logOrderAttempt(Order order) {
        // Transaction T2 - independent
        auditRepository.save(new AuditLog(order));
        // T2 commits immediately
    }
}

3. NESTED

Behavior: Execute within nested transaction if exists, otherwise like REQUIRED

@Transactional(propagation = Propagation.REQUIRED)
public void methodA() {
    // Transaction T1
    methodB();  // Nested transaction (savepoint)
}

@Transactional(propagation = Propagation.NESTED)
public void methodB() {
    // Nested transaction with savepoint
    // Can rollback to savepoint without affecting T1
}

Flow:

methodA() - Transaction T1
    ↓
Call methodB()
    ↓
Create Savepoint S1
    ↓
Execute methodB()
    ↓
Exception in methodB()?
    ├─ Yes → Rollback to S1 (T1 continues)
    └─ No → Release S1
    ↓
Continue T1
    ↓
Commit T1

Use Case: Partial rollback scenarios

4. MANDATORY

Behavior: Must have existing transaction, throw exception if none

@Transactional(propagation = Propagation.MANDATORY)
public void methodB() {
    // Must be called within existing transaction
    // Throws exception if no transaction exists
}

Use Case: Enforce transaction requirement for critical operations

5. SUPPORTS

Behavior: Use transaction if exists, execute non-transactionally if none

@Transactional(propagation = Propagation.SUPPORTS)
public void methodB() {
    // Uses transaction if exists
    // Runs without transaction if none exists
}

Use Case: Read operations that can work with or without transactions

6. NOT_SUPPORTED

Behavior: Execute non-transactionally, suspend existing transaction

@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void methodB() {
    // Always executes without transaction
    // Suspends existing transaction if any
}

Use Case: Operations that shouldn't be transactional (e.g., sending emails)

7. NEVER

Behavior: Execute non-transactionally, throw exception if transaction exists

@Transactional(propagation = Propagation.NEVER)
public void methodB() {
    // Must NOT be called within transaction
    // Throws exception if transaction exists
}

Use Case: Enforce non-transactional execution

Propagation Comparison Table

┌─────────────────┬──────────────┬─────────────┬──────────────┐
│   Propagation   │ Has Trans?   │   Action    │  New Trans?  │
├─────────────────┼──────────────┼─────────────┼──────────────┤
│ REQUIRED        │ Yes          │ Use it      │ No           │
│ (Default)       │ No           │ Create new  │ Yes          │
├─────────────────┼──────────────┼─────────────┼──────────────┤
│ REQUIRES_NEW    │ Yes          │ Suspend     │ Yes          │
│                 │ No           │ Create new  │ Yes          │
├─────────────────┼──────────────┼─────────────┼──────────────┤
│ NESTED          │ Yes          │ Savepoint   │ No           │
│                 │ No           │ Create new  │ Yes          │
├─────────────────┼──────────────┼─────────────┼──────────────┤
│ MANDATORY       │ Yes          │ Use it      │ No           │
│                 │ No           │ Exception   │ N/A          │
├─────────────────┼──────────────┼─────────────┼──────────────┤
│ SUPPORTS        │ Yes          │ Use it      │ No           │
│                 │ No           │ No trans    │ No           │
├─────────────────┼──────────────┼─────────────┼──────────────┤
│ NOT_SUPPORTED   │ Yes          │ Suspend     │ No           │
│                 │ No           │ No trans    │ No           │
├─────────────────┼──────────────┼─────────────┼──────────────┤
│ NEVER           │ Yes          │ Exception   │ N/A          │
│                 │ No           │ No trans    │ No           │
└─────────────────┴──────────────┴─────────────┴──────────────┘

Transaction Isolation Levels

Definition: Degree to which one transaction is isolated from others

Isolation Problems

1. Dirty Read

Reading uncommitted data from another transaction

Transaction 1: UPDATE account SET balance = 500 WHERE id = 1
Transaction 2: SELECT balance FROM account WHERE id = 1  → Reads 500
Transaction 1: ROLLBACK  → Balance back to original

Problem: Transaction 2 read uncommitted data (dirty read)

2. Non-Repeatable Read

Same query returns different results within same transaction

Transaction 1: SELECT balance FROM account WHERE id = 1  → Reads 1000
Transaction 2: UPDATE account SET balance = 500 WHERE id = 1
Transaction 2: COMMIT
Transaction 1: SELECT balance FROM account WHERE id = 1  → Reads 500

Problem: Same query, different results (non-repeatable read)

3. Phantom Read

New rows appear in subsequent queries

Transaction 1: SELECT COUNT(*) FROM account WHERE balance > 1000  → Returns 5
Transaction 2: INSERT INTO account VALUES (6, 1500)
Transaction 2: COMMIT
Transaction 1: SELECT COUNT(*) FROM account WHERE balance > 1000  → Returns 6

Problem: New rows appeared (phantom read)

Isolation Levels

1. READ_UNCOMMITTED (Lowest Isolation)

@Transactional(isolation = Isolation.READ_UNCOMMITTED)
public void method() {
    // Can read uncommitted changes from other transactions
}
  • Problems: ❌ Dirty read, ❌ Non-repeatable read, ❌ Phantom read
  • Performance: ⚡ Fastest
  • Use Case: Rarely used, only for non-critical data

2. READ_COMMITTED (Default in Most Databases)

@Transactional(isolation = Isolation.READ_COMMITTED)
public void method() {
    // Can only read committed changes
}
  • Problems: ✅ Prevents dirty read, ❌ Non-repeatable read, ❌ Phantom read
  • Performance: ⚡ Good
  • Use Case: Most common, good balance

3. REPEATABLE_READ

@Transactional(isolation = Isolation.REPEATABLE_READ)
public void method() {
    // Same query returns same results
}
  • Problems: ✅ Prevents dirty read, ✅ Prevents non-repeatable read, ❌ Phantom read
  • Performance: 🐌 Slower
  • Use Case: When consistency within transaction is critical

4. SERIALIZABLE (Highest Isolation)

@Transactional(isolation = Isolation.SERIALIZABLE)
public void method() {
    // Complete isolation, transactions execute serially
}
  • Problems: ✅ Prevents all problems
  • Performance: 🐌 Slowest
  • Use Case: Critical financial transactions

Isolation Level Comparison

┌──────────────────┬─────────────┬──────────────────┬──────────────┬─────────────┐
│ Isolation Level  │ Dirty Read  │ Non-Repeatable   │ Phantom Read │ Performance │
├──────────────────┼─────────────┼──────────────────┼──────────────┼─────────────┤
│ READ_UNCOMMITTED │ Possible    │ Possible         │ Possible     │ Fastest     │
├──────────────────┼─────────────┼──────────────────┼──────────────┼─────────────┤
│ READ_COMMITTED   │ Prevented   │ Possible         │ Possible     │ Fast        │
├──────────────────┼─────────────┼──────────────────┼──────────────┼─────────────┤
│ REPEATABLE_READ  │ Prevented   │ Prevented        │ Possible     │ Slow        │
├──────────────────┼─────────────┼──────────────────┼──────────────┼─────────────┤
│ SERIALIZABLE     │ Prevented   │ Prevented        │ Prevented    │ Slowest     │
└──────────────────┴─────────────┴──────────────────┴──────────────┴─────────────┘

Transaction Rollback

Default Rollback Behavior

@Transactional
public void method() {
    // Rolls back on RuntimeException and Error
    // Does NOT rollback on checked exceptions
}

Rollback Decision Flow:

Method execution
    ↓
Exception thrown?
    ├─ RuntimeException → Rollback
    ├─ Error → Rollback
    ├─ Checked Exception → Commit (!)
    └─ No exception → Commit

Custom Rollback Rules

// Rollback on all exceptions
@Transactional(rollbackFor = Exception.class)
public void method() {
    // Rolls back on any exception
}

// Don't rollback on specific exceptions
@Transactional(noRollbackFor = BusinessException.class)
public void method() {
    // Commits even if BusinessException thrown
}

// Combine rules
@Transactional(
    rollbackFor = {SQLException.class, IOException.class},
    noRollbackFor = {BusinessException.class}
)
public void method() {
    // Custom rollback behavior
}

Real-World Examples

Example 1: E-commerce Order Processing

@Service
public class OrderService {
    
    @Autowired
    private OrderRepository orderRepository;
    
    @Autowired
    private InventoryService inventoryService;
    
    @Autowired
    private PaymentService paymentService;
    
    @Autowired
    private NotificationService notificationService;
    
    @Transactional(
        isolation = Isolation.READ_COMMITTED,
        timeout = 30,
        rollbackFor = Exception.class
    )
    public Order processOrder(OrderRequest request) {
        // 1. Create order
        Order order = new Order(request);
        order.setStatus(OrderStatus.PENDING);
        orderRepository.save(order);
        
        // 2. Reserve inventory
        inventoryService.reserveItems(order.getItems());
        
        // 3. Process payment
        Payment payment = paymentService.processPayment(order);
        order.setPayment(payment);
        
        // 4. Update order status
        order.setStatus(OrderStatus.CONFIRMED);
        orderRepository.save(order);
        
        // 5. Send notification (separate transaction)
        notificationService.sendOrderConfirmation(order);
        
        return order;
    }
}

@Service
public class NotificationService {
    
    // Separate transaction - must succeed even if order fails later
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void sendOrderConfirmation(Order order) {
        emailService.send(
            order.getCustomer().getEmail(), 
            "Order Confirmation", 
            "Your order " + order.getId() + " is confirmed"
        );
    }
}

Transaction Flow:

processOrder() called
    ↓
Transaction T1 begins
    ↓
1. Save order (PENDING)
    ↓
2. Reserve inventory
    ↓
3. Process payment
    ↓
4. Update order (CONFIRMED)
    ↓
5. Send notification
    ├─ Suspend T1
    ├─ Transaction T2 begins
    ├─ Send email
    ├─ Commit T2
    └─ Resume T1
    ↓
All successful?
    ├─ Yes → Commit T1 → Order confirmed
    └─ No → Rollback T1 → Order cancelled
                         → But email already sent (T2 committed)

Example 2: Banking Transfer with Audit

@Service
public class BankingService {
    
    @Autowired
    private AccountRepository accountRepository;
    
    @Autowired
    private AuditService auditService;
    
    @Transactional(
        isolation = Isolation.SERIALIZABLE,  // Highest isolation for money
        timeout = 10,
        rollbackFor = Exception.class
    )
    public TransferResult transfer(Long fromId, Long toId, BigDecimal amount) {
        // Log attempt (must persist even if transfer fails)
        auditService.logTransferAttempt(fromId, toId, amount);
        
        // 1. Validate accounts
        Account fromAccount = accountRepository.findById(fromId)
            .orElseThrow(() -> new AccountNotFoundException(fromId));
        Account toAccount = accountRepository.findById(toId)
            .orElseThrow(() -> new AccountNotFoundException(toId));
        
        // 2. Validate balance
        if (fromAccount.getBalance().compareTo(amount) < 0) {
            throw new InsufficientFundsException();
        }
        
        // 3. Perform transfer
        fromAccount.setBalance(fromAccount.getBalance().subtract(amount));
        toAccount.setBalance(toAccount.getBalance().add(amount));
        
        accountRepository.save(fromAccount);
        accountRepository.save(toAccount);
        
        // 4. Log success
        auditService.logTransferSuccess(fromId, toId, amount);
        
        return new TransferResult(true, "Transfer successful");
    }
}

@Service
public class AuditService {
    
    @Autowired
    private AuditRepository auditRepository;
    
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logTransferAttempt(Long fromId, Long toId, BigDecimal amount) {
        AuditLog log = new AuditLog();
        log.setType("TRANSFER_ATTEMPT");
        log.setFromAccount(fromId);
        log.setToAccount(toId);
        log.setAmount(amount);
        log.setTimestamp(LocalDateTime.now());
        auditRepository.save(log);
    }
    
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logTransferSuccess(Long fromId, Long toId, BigDecimal amount) {
        AuditLog log = new AuditLog();
        log.setType("TRANSFER_SUCCESS");
        log.setFromAccount(fromId);
        log.setToAccount(toId);
        log.setAmount(amount);
        log.setTimestamp(LocalDateTime.now());
        auditRepository.save(log);
    }
}

Complete Flow:

transfer() called
    ↓
Log attempt (T2 - REQUIRES_NEW)
    ├─ Suspend T1
    ├─ Begin T2
    ├─ Save audit log
    ├─ Commit T2 (persisted)
    └─ Resume T1
    ↓
Transaction T1 continues
    ↓
Validate accounts
    ↓
Check balance
    ↓
Insufficient funds?
    ├─ Yes → Throw exception
    │         ↓
    │      Rollback T1
    │         ↓
    │      Transfer failed
    │         ↓
    │      But audit log persisted (T2)
    │
    └─ No → Continue
        ↓
    Deduct from source
        ↓
    Add to destination
        ↓
    Save both accounts
        ↓
    Log success (T3 - REQUIRES_NEW)
        ├─ Suspend T1
        ├─ Begin T3
        ├─ Save success log
        ├─ Commit T3
        └─ Resume T1
        ↓
    Commit T1
        ↓
    Transfer successful

Best Practices

1. Keep Transactions Short

// ❌ Bad: Long transaction
@Transactional
public void processOrder(Order order) {
    orderRepository.save(order);
    
    // Long-running operation
    emailService.sendConfirmation(order);  // 5 seconds
    
    // External API call
    shippingService.scheduleDelivery(order);  // 10 seconds
    
    // Transaction held for 15+ seconds!
}

// ✅ Good: Short transaction
@Transactional
public void processOrder(Order order) {
    orderRepository.save(order);
    // Transaction ends here
}

public void sendNotifications(Order order) {
    // Non-transactional
    emailService.sendConfirmation(order);
    shippingService.scheduleDelivery(order);
}

2. Use Read-Only for Queries

// ✅ Good: Read-only optimization
@Transactional(readOnly = true)
public List<User> getAllUsers() {
    return userRepository.findAll();
}

Benefits:

  • ⚡ Performance optimization
  • 🔒 Prevents accidental modifications
  • 📊 Database can optimize read-only queries

3. Set Appropriate Timeout

@Transactional(timeout = 5)  // 5 seconds
public void quickOperation() {
    // Must complete within 5 seconds
}

4. Handle Exceptions Properly

@Transactional(rollbackFor = Exception.class)
public void method() {
    try {
        // Business logic
    } catch (SpecificException e) {
        // Handle but still rollback
        log.error("Error occurred", e);
        throw e;  // Re-throw to trigger rollback
    }
}

5. Avoid Redundant Transaction Annotations

// ❌ Bad: Unnecessary nesting
@Transactional
public void methodA() {
    methodB();  // Same transaction, unnecessary annotation
}

@Transactional  // Redundant
public void methodB() {
    // Uses same transaction as methodA
}

// ✅ Good: Only annotate entry point
@Transactional
public void methodA() {
    methodB();  // No annotation needed
}

public void methodB() {
    // Participates in methodA's transaction
}

Common Pitfalls

1. Self-Invocation Problem

@Service
public class UserService {
    
    public void publicMethod() {
        // Direct call - no proxy, no transaction!
        this.transactionalMethod();
    }
    
    @Transactional
    private void transactionalMethod() {
        // Transaction NOT applied!
    }
}

// ✅ Solution 1: Inject self
@Service
public class UserService {
    
    @Autowired
    private UserService self;
    
    public void publicMethod() {
        // Goes through proxy - transaction applied
        self.transactionalMethod();
    }
    
    @Transactional
    public void transactionalMethod() {
        // Transaction applied
    }
}

// ✅ Solution 2: Separate service
@Service
public class UserService {
    
    @Autowired
    private TransactionalUserService transactionalService;
    
    public void publicMethod() {
        transactionalService.transactionalMethod();
    }
}

@Service
public class TransactionalUserService {
    
    @Transactional
    public void transactionalMethod() {
        // Transaction applied
    }
}

2. Catching Exceptions Without Re-throwing

// ❌ Bad: Exception swallowed
@Transactional
public void method() {
    try {
        // Business logic
    } catch (Exception e) {
        log.error("Error", e);
        // Exception not re-thrown - transaction commits!
    }
}

// ✅ Good: Re-throw exception
@Transactional
public void method() {
    try {
        // Business logic
    } catch (Exception e) {
        log.error("Error", e);
        throw e;  // Transaction rolls back
    }
}

3. Wrong Isolation Level

// ❌ Bad: Too high isolation for simple query
@Transactional(isolation = Isolation.SERIALIZABLE)
public List<User> getUsers() {
    return userRepository.findAll();
}

// ✅ Good: Appropriate isolation
@Transactional(readOnly = true, isolation = Isolation.READ_COMMITTED)
public List<User> getUsers() {
    return userRepository.findAll();
}

4. Checked Exceptions Don't Rollback by Default

// ❌ Bad: Checked exception doesn't rollback
@Transactional
public void method() throws IOException {
    // Business logic
    throw new IOException();  // Transaction commits!
}

// ✅ Good: Specify rollback for checked exceptions
@Transactional(rollbackFor = Exception.class)
public void method() throws IOException {
    // Business logic
    throw new IOException();  // Transaction rolls back
}

Conclusion

Spring Transaction Management is essential for maintaining data integrity in enterprise applications.

Key Takeaways

Use declarative transactions (@Transactional) for clean code
Understand propagation behaviors for complex scenarios
Choose appropriate isolation levels based on requirements
Keep transactions short to avoid performance issues
Handle exceptions properly to ensure correct rollback
Avoid common pitfalls like self-invocation and exception swallowing
Use read-only transactions for queries
Set timeouts to prevent long-running transactions

Quick Reference

// Most common pattern
@Transactional(
    propagation = Propagation.REQUIRED,
    isolation = Isolation.READ_COMMITTED,
    rollbackFor = Exception.class,
    timeout = 30
)
public void businessMethod() {
    // Your business logic
}

Master these concepts, and you'll write robust, reliable transactional code in Spring applications!