Skip to content
Home / Skills / Spring / Transactions
SP

Transactions

Spring core v1.0.0

Spring Transaction Management

Overview

Spring’s declarative transaction management provides a consistent programming model across different transaction APIs (JDBC, JPA, JTA). This skill covers transaction propagation, isolation levels, rollback rules, and distributed transaction patterns.


Key Concepts

Transaction Properties (ACID)

PropertyDescription
AtomicityAll or nothing - transaction completes fully or rolls back
ConsistencyDatabase moves from valid state to valid state
IsolationConcurrent transactions don’t interfere
DurabilityCommitted changes persist

Isolation Levels

┌─────────────────────────────────────────────────────────────┐
│                    Isolation Levels                          │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  READ_UNCOMMITTED   ← Dirty reads possible                  │
│         ↓                                                   │
│  READ_COMMITTED     ← Default for most DBs                  │
│         ↓             No dirty reads                        │
│  REPEATABLE_READ    ← Same read returns same data           │
│         ↓                                                   │
│  SERIALIZABLE       ← Full isolation (slowest)              │
│                                                              │
│  Phenomena:                                                  │
│  ┌─────────────────┬───────┬──────────┬───────────────┐    │
│  │ Level           │Dirty  │Non-repeat│Phantom        │    │
│  │                 │Read   │Read      │Read           │    │
│  ├─────────────────┼───────┼──────────┼───────────────┤    │
│  │READ_UNCOMMITTED │  ✓    │    ✓     │     ✓         │    │
│  │READ_COMMITTED   │  ✗    │    ✓     │     ✓         │    │
│  │REPEATABLE_READ  │  ✗    │    ✗     │     ✓         │    │
│  │SERIALIZABLE     │  ✗    │    ✗     │     ✗         │    │
│  └─────────────────┴───────┴──────────┴───────────────┘    │
│                                                              │
└─────────────────────────────────────────────────────────────┘

Propagation Behaviors

PropagationDescription
REQUIREDJoin existing or create new (default)
REQUIRES_NEWAlways create new, suspend existing
SUPPORTSJoin if exists, otherwise non-transactional
NOT_SUPPORTEDSuspend existing, run non-transactional
MANDATORYMust have existing, throw if none
NEVERThrow if transaction exists
NESTEDNested transaction with savepoint

Best Practices

1. Use Declarative Transactions

Prefer @Transactional over programmatic transaction management.

2. Keep Transactions Short

Long transactions hold locks and reduce concurrency.

3. Mark Read-Only Where Appropriate

@Transactional(readOnly = true) enables optimizations.

4. Handle Rollback Explicitly

Define which exceptions trigger rollback.

5. Be Careful with Self-Invocation

Calling a transactional method from within the same class bypasses the proxy.


Code Examples

Example 1: Basic Transaction Configuration

@Service
@RequiredArgsConstructor
@Transactional // Class-level default
public class OrderService {
    
    private final OrderRepository orderRepository;
    private final PaymentService paymentService;
    private final InventoryService inventoryService;
    private final EventPublisher eventPublisher;
    
    // Uses class-level @Transactional (REQUIRED propagation)
    public Order createOrder(CreateOrderCommand command) {
        Order order = Order.create(command);
        order = orderRepository.save(order);
        
        // All these operations are in the same transaction
        inventoryService.reserve(order.getItems());
        paymentService.authorize(order.getPaymentDetails());
        
        eventPublisher.publish(new OrderCreatedEvent(order));
        
        return order;
    }
    
    // Override for read-only operations
    @Transactional(readOnly = true)
    public Order findById(OrderId orderId) {
        return orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));
    }
    
    // Explicit rollback rules
    @Transactional(
        rollbackFor = BusinessException.class,
        noRollbackFor = WarningException.class
    )
    public Order processOrder(OrderId orderId) {
        Order order = findById(orderId);
        
        try {
            order.process();
            return orderRepository.save(order);
        } catch (InsufficientInventoryException e) {
            // This will rollback (extends BusinessException)
            throw e;
        } catch (WarningException e) {
            // This won't rollback
            log.warn("Non-critical issue", e);
            return order;
        }
    }
    
    // Timeout for long operations
    @Transactional(timeout = 30) // 30 seconds
    public Order processLargeOrder(OrderId orderId) {
        // Complex processing that might take time
        return orderRepository.findById(orderId)
            .map(this::processAllItems)
            .orElseThrow();
    }
}

Example 2: Propagation Behaviors

@Service
@RequiredArgsConstructor
@Slf4j
public class PropagationExamples {
    
    private final OrderRepository orderRepository;
    private final AuditService auditService;
    private final NotificationService notificationService;
    
    // REQUIRED (default) - joins existing or creates new
    @Transactional(propagation = Propagation.REQUIRED)
    public void requiredExample() {
        // If called within existing transaction, joins it
        // If no transaction exists, creates one
        orderRepository.save(order);
    }
    
    // REQUIRES_NEW - always creates new transaction
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void requiresNewExample(AuditEntry entry) {
        // Current transaction is suspended
        // New transaction is created
        // Useful for audit logs that must persist even if parent rolls back
        auditService.save(entry);
    }
    
    // SUPPORTS - uses transaction if available
    @Transactional(propagation = Propagation.SUPPORTS)
    public Order getOrder(OrderId orderId) {
        // Works with or without transaction
        // Read-only operations often use this
        return orderRepository.findById(orderId).orElseThrow();
    }
    
    // MANDATORY - must have existing transaction
    @Transactional(propagation = Propagation.MANDATORY)
    public void mandatoryExample() {
        // Throws if no transaction exists
        // Useful for methods that should never be called standalone
    }
    
    // NESTED - creates savepoint within existing transaction
    @Transactional(propagation = Propagation.NESTED)
    public void nestedExample() {
        // Rolls back to savepoint on failure
        // Parent transaction can choose to continue
        // Note: Not all databases support savepoints
    }
    
    // Practical example: Order with independent audit logging
    @Transactional
    public Order createOrderWithAudit(CreateOrderCommand command) {
        Order order = Order.create(command);
        order = orderRepository.save(order);
        
        try {
            // Audit should persist even if notification fails
            auditService.logOrderCreation(order); // REQUIRES_NEW
        } catch (Exception e) {
            log.error("Audit failed", e);
            // Don't fail order creation for audit failure
        }
        
        try {
            // Notification failure shouldn't rollback order
            notificationService.notifyOrderCreated(order); // REQUIRES_NEW
        } catch (Exception e) {
            log.warn("Notification failed", e);
        }
        
        return order;
    }
}

@Service
public class AuditService {
    
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logOrderCreation(Order order) {
        // Independent transaction - persists regardless of caller
        auditRepository.save(new AuditEntry("ORDER_CREATED", order.getId()));
    }
}

Example 3: Isolation Levels

@Service
@RequiredArgsConstructor
public class IsolationExamples {
    
    // READ_COMMITTED - default, prevents dirty reads
    @Transactional(isolation = Isolation.READ_COMMITTED)
    public BigDecimal getAccountBalance(AccountId accountId) {
        return accountRepository.findBalanceById(accountId);
    }
    
    // REPEATABLE_READ - prevents non-repeatable reads
    @Transactional(isolation = Isolation.REPEATABLE_READ)
    public TransferResult transfer(AccountId from, AccountId to, BigDecimal amount) {
        // Reading same account twice will return same value
        BigDecimal fromBalance = accountRepository.findBalanceById(from);
        
        if (fromBalance.compareTo(amount) < 0) {
            throw new InsufficientFundsException(from, amount);
        }
        
        // Even if another transaction modifies, we see consistent data
        accountRepository.debit(from, amount);
        accountRepository.credit(to, amount);
        
        return TransferResult.success(from, to, amount);
    }
    
    // SERIALIZABLE - strongest isolation
    @Transactional(isolation = Isolation.SERIALIZABLE)
    public void criticalFinancialOperation() {
        // Full isolation, no concurrent modifications
        // Use sparingly - can cause lock contention
    }
    
    // Optimistic locking as alternative to pessimistic isolation
    @Transactional
    public Order updateOrderOptimistic(OrderId orderId, UpdateOrderCommand command) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow();
        
        // @Version field in entity handles optimistic locking
        order.update(command);
        
        try {
            return orderRepository.save(order);
        } catch (OptimisticLockingFailureException e) {
            throw new ConcurrentModificationException(
                "Order was modified by another transaction", e);
        }
    }
}

// Entity with optimistic locking
@Entity
public class Order {
    @Id
    private OrderId id;
    
    @Version
    private Long version; // Incremented on each update
    
    // ...
}

Example 4: Programmatic Transactions

@Service
@RequiredArgsConstructor
public class ProgrammaticTransactionExamples {
    
    private final TransactionTemplate transactionTemplate;
    private final PlatformTransactionManager transactionManager;
    private final OrderRepository orderRepository;
    
    // TransactionTemplate for simple cases
    public Order createOrderProgrammatic(CreateOrderCommand command) {
        return transactionTemplate.execute(status -> {
            try {
                Order order = Order.create(command);
                return orderRepository.save(order);
            } catch (BusinessException e) {
                status.setRollbackOnly();
                throw e;
            }
        });
    }
    
    // Fine-grained control with TransactionManager
    public void complexOperation() {
        TransactionDefinition def = new DefaultTransactionDefinition();
        TransactionStatus status = transactionManager.getTransaction(def);
        
        try {
            // Multiple operations
            step1();
            step2();
            step3();
            
            transactionManager.commit(status);
        } catch (Exception e) {
            transactionManager.rollback(status);
            throw e;
        }
    }
    
    // Mixing declarative and programmatic
    @Transactional
    public void mixedApproach() {
        // Outer transaction from annotation
        
        // Inner programmatic transaction
        transactionTemplate.setPropagationBehavior(
            TransactionDefinition.PROPAGATION_REQUIRES_NEW);
        
        transactionTemplate.execute(status -> {
            // Independent transaction
            auditLog("operation started");
            return null;
        });
        
        // Continue with outer transaction
        performMainOperation();
    }
    
    // Read-only template
    public Order findOrderReadOnly(OrderId orderId) {
        TransactionTemplate readOnlyTemplate = new TransactionTemplate(transactionManager);
        readOnlyTemplate.setReadOnly(true);
        
        return readOnlyTemplate.execute(status ->
            orderRepository.findById(orderId).orElseThrow()
        );
    }
}

Example 5: Transaction Event Listeners

@Service
@RequiredArgsConstructor
public class TransactionEventExamples {
    
    private final ApplicationEventPublisher eventPublisher;
    
    @Transactional
    public Order createOrder(CreateOrderCommand command) {
        Order order = Order.create(command);
        order = orderRepository.save(order);
        
        // Event published, but listeners may run at different phases
        eventPublisher.publishEvent(new OrderCreatedEvent(order));
        
        return order;
    }
}

@Component
@Slf4j
public class OrderEventListener {
    
    // Runs after transaction commits successfully
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onOrderCreated(OrderCreatedEvent event) {
        log.info("Order committed, sending notification");
        // Safe to notify external systems
        notificationService.sendOrderConfirmation(event.getOrder());
    }
    
    // Runs after transaction rolls back
    @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
    public void onOrderRollback(OrderCreatedEvent event) {
        log.warn("Order creation rolled back: {}", event.getOrder().getId());
        // Cleanup any external state
    }
    
    // Runs before commit (can still cause rollback)
    @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
    public void validateBeforeCommit(OrderCreatedEvent event) {
        if (!validator.isValid(event.getOrder())) {
            throw new ValidationException("Order invalid");
        }
    }
    
    // Fallback if no transaction (useful for testing)
    @TransactionalEventListener(fallbackExecution = true)
    public void handleWithFallback(OrderCreatedEvent event) {
        // Executes immediately if no transaction
    }
}

// Custom transaction synchronization
@Component
public class TransactionCallback implements TransactionSynchronization {
    
    @Override
    public void beforeCommit(boolean readOnly) {
        // Called before commit
    }
    
    @Override
    public void afterCommit() {
        // Called after successful commit
    }
    
    @Override
    public void afterCompletion(int status) {
        if (status == STATUS_COMMITTED) {
            // Handle commit
        } else if (status == STATUS_ROLLED_BACK) {
            // Handle rollback
        }
    }
}

@Service
public class ServiceWithCallback {
    
    @Transactional
    public void operationWithCallback() {
        // Register callback for current transaction
        TransactionSynchronizationManager.registerSynchronization(
            new TransactionSynchronization() {
                @Override
                public void afterCommit() {
                    // Cleanup after commit
                }
            }
        );
        
        // Perform operations
    }
}

Anti-Patterns

❌ Self-Invocation Bypasses Proxy

@Service
public class BrokenService {
    
    public void outerMethod() {
        // This calls the actual method, not the proxy!
        innerMethod(); // No transaction!
    }
    
    @Transactional
    public void innerMethod() {
        // Expected transaction, but it won't work
    }
}

// ✅ Fix with self-injection
@Service
public class FixedService {
    @Lazy @Autowired
    private FixedService self;
    
    public void outerMethod() {
        self.innerMethod(); // Calls through proxy
    }
    
    @Transactional
    public void innerMethod() {
        // Transaction works now
    }
}

❌ Catching Exceptions That Should Rollback

// WRONG - transaction commits despite error
@Transactional
public void brokenRollback() {
    try {
        orderRepository.save(order);
        paymentService.charge(order); // Throws exception
    } catch (PaymentException e) {
        log.error("Payment failed", e);
        // Transaction still commits!
    }
}

// ✅ CORRECT - rethrow or mark for rollback
@Transactional
public void correctRollback() {
    try {
        orderRepository.save(order);
        paymentService.charge(order);
    } catch (PaymentException e) {
        throw e; // Rethrow to trigger rollback
    }
}

Testing Strategies

@SpringBootTest
@Transactional // Rolls back after each test
class OrderServiceTest {
    
    @Autowired
    private OrderService orderService;
    
    @Autowired
    private TestEntityManager entityManager;
    
    @Test
    void shouldCreateOrder() {
        Order order = orderService.createOrder(command);
        
        entityManager.flush();
        entityManager.clear();
        
        Order found = entityManager.find(Order.class, order.getId());
        assertThat(found).isNotNull();
    }
    
    @Test
    void shouldRollbackOnPaymentFailure() {
        when(paymentService.charge(any())).thenThrow(new PaymentException());
        
        assertThatThrownBy(() -> orderService.createOrder(command))
            .isInstanceOf(PaymentException.class);
        
        // Order should not exist
        assertThat(orderRepository.count()).isZero();
    }
}

References