Zademy

Spring Programmatic Transaction Management: A Practical Guide for Beginners

spring-boot; transactions
words words

Spring's @Transactional annotation is great for declarative transaction management, but it's not always the best choice. In this jotting, you'll learn when and how to use programmatic transaction control in Spring, ideal for situations where you need fine-grained management of transaction lifecycles.

Why You Need Programmatic Transactions?

The Connection Pool Exhaustion Problem

Imagine this common scenario: a method that combines database calls with an external API:

@Transactional
public void processPayment(PaymentRequest request) {
    savePaymentRequest(request);              // DB
    callPaymentProviderApi(request);          // External API (slow)
    updatePaymentState(request);              // DB
    saveAuditHistory(request);                // DB
}

What's the problem here?

When Spring creates the transaction with @Transactional:

  1. It takes a connection from the pool and keeps it during the ENTIRE method
  2. The connection remains occupied while waiting for the external API response
  3. If the API takes 5-10 seconds, that connection is blocked all that time
  4. Under high load, you exhaust all available connections waiting for slow APIs

Golden rule: Never mix database operations with external API calls within the same transaction.

TransactionTemplate provides a callback-based API for manually managing transactions. It's the cleanest and most modern way to work with programmatic transactions.

Basic Configuration

import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.stereotype.Component;

@Component
public class PaymentService {
    
    private final TransactionTemplate transactionTemplate;
    
    public PaymentService(PlatformTransactionManager transactionManager) {
        this.transactionTemplate = new TransactionTemplate(transactionManager);
    }
}

Note: Spring Boot automatically configures a PlatformTransactionManager. You just need to inject it.

Example 1: Transaction with Return Value

public Long createSuccessfulPayment(PaymentRequest request) {
    // Execute code within a transaction and return the ID
    Long paymentId = transactionTemplate.execute(status -> {
        Payment payment = new Payment();
        payment.setAmount(request.getAmount());
        payment.setReferenceNumber(request.getReference());
        payment.setState(Payment.State.SUCCESSFUL);
        
        entityManager.persist(payment);
        
        // ID is auto-generated after persist
        return payment.getId();
    });
    
    return paymentId;
}

What does this do?

  • Automatically creates a transaction
  • Executes the lambda code inside the transaction
  • If everything goes well, automatically commits
  • If there's an exception, automatically rolls back
  • Returns the value from the lambda

Example 2: Automatic Rollback on Exception

public void createTwoPaymentsWithRollback() {
    try {
        transactionTemplate.execute(status -> {
            Payment first = new Payment();
            first.setReferenceNumber("REF-001");
            first.setAmount(1000L);
            entityManager.persist(first);  // OK
            
            Payment second = new Payment();
            second.setReferenceNumber("REF-001"); // Duplicate!
            second.setAmount(2000L);
            entityManager.persist(second);  // Throws exception
            
            return null;
        });
    } catch (Exception e) {
        // First payment ALSO gets reverted - guaranteed atomicity
        System.out.println("Transaction rolled back: " + e.getMessage());
    }
}

Example 3: Explicit Manual Rollback

public Long createPaymentWithValidation(PaymentRequest request) {
    return transactionTemplate.execute(status -> {
        Payment payment = new Payment();
        payment.setReferenceNumber(request.getReference());
        payment.setAmount(request.getAmount());
        
        entityManager.persist(payment);
        
        // Custom business validation
        if (request.getAmount() > 100000) {
            // Mark for rollback - transaction will be reverted
            status.setRollbackOnly();
            return null;  // Or throw custom exception
        }
        
        return payment.getId();
    });
}

Example 4: Transaction Without Return Value

public void saveAuditLogEntry(AuditEntry entry) {
    transactionTemplate.execute(new TransactionCallbackWithoutResult() {
        @Override
        protected void doInTransactionWithoutResult(TransactionStatus status) {
            auditRepository.save(entry);
            // Returns nothing, just executes operations
        }
    });
}

Example 5: Custom Configuration per Instance

You can create multiple TransactionTemplate instances with different configurations:

@Component
public class TransactionConfig {
    
    @Bean
    public TransactionTemplate readOnlyTransactionTemplate(PlatformTransactionManager txManager) {
        TransactionTemplate template = new TransactionTemplate(txManager);
        template.setReadOnly(true);  // Optimization for queries
        return template;
    }
    
    @Bean
    public TransactionTemplate serializableTransactionTemplate(PlatformTransactionManager txManager) {
        TransactionTemplate template = new TransactionTemplate(txManager);
        template.setIsolationLevel(TransactionDefinition.ISOLATION_SERIALIZABLE);
        template.setTimeout(30);  // Seconds
        return template;
    }
    
    @Bean
    public TransactionTemplate requiresNewTransactionTemplate(PlatformTransactionManager txManager) {
        TransactionTemplate template = new TransactionTemplate(txManager);
        template.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
        return template;
    }
}

Available Configurations

PropertyValueDescription
setIsolationLevel()ISOLATION_READ_UNCOMMITTEDRead uncommitted isolation level
ISOLATION_READ_COMMITTEDReads only committed data
ISOLATION_REPEATABLE_READPrevents non-repeatable reads
ISOLATION_SERIALIZABLEMaximum isolation
setPropagationBehavior()PROPAGATION_REQUIREDUse existing or create new
PROPAGATION_REQUIRES_NEWAlways create a new transaction
PROPAGATION_NESTEDNested transaction (savepoint)
setTimeout()Seconds (int)Maximum execution time
setReadOnly()true/falseOptimization for read-only

Solution 2: PlatformTransactionManager (Low Level)

For total control, use PlatformTransactionManager directly. It's the API used internally by both @Transactional and TransactionTemplate.

Example: Complete Manual Control

import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.DefaultTransactionDefinition;

@Component
public class ManualTransactionService {
    
    private final PlatformTransactionManager transactionManager;
    
    public ManualTransactionService(PlatformTransactionManager txManager) {
        this.transactionManager = txManager;
    }
    
    public void processWithTotalControl() {
        // 1. Define transaction configuration
        DefaultTransactionDefinition definition = new DefaultTransactionDefinition();
        definition.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ);
        definition.setTimeout(5);  // 5 seconds
        definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
        
        // 2. Start the transaction
        TransactionStatus status = transactionManager.getTransaction(definition);
        
        try {
            // 3. Execute business operations
            Payment payment = new Payment();
            payment.setAmount(500L);
            payment.setReferenceNumber("MANUAL-001");
            entityManager.persist(payment);
            
            // 4. Commit the transaction
            transactionManager.commit(status);
            
        } catch (Exception ex) {
            // 5. Rollback on error
            transactionManager.rollback(status);
            throw new RuntimeException("Error in manual transaction", ex);
        }
    }
}

Comparison: TransactionTemplate vs PlatformTransactionManager

AspectTransactionTemplatePlatformTransactionManager
Abstraction levelHigh (callbacks)Low (manual)
Error handlingAutomaticManual (try-catch)
Automatic rollbackYesNo (you must call it)
Resulting codeCleanerMore verbose
FlexibilityLimited by callbackTotal control
Recommended useMost casesWhen you need fine control

Practical Case: Separating DB from External API

Original problem (with exhaustion risk):

@Transactional
public void processOrder(Order order) {
    orderRepository.save(order);              // DB
    shippingApi.createShipment(order);        // Slow API
    notificationService.notifyCustomer(order); // Slow API
}

Solution with TransactionTemplate:

@Component
public class OrderService {
    
    private final TransactionTemplate txTemplate;
    private final OrderRepository orderRepository;
    private final ShippingApiService shippingApi;
    private final NotificationService notificationService;
    
    public void processOrderSafely(Order order) {
        // Step 1: Only DB part in transaction
        Long orderId = txTemplate.execute(status -> {
            order.setStatus("PROCESSING");
            orderRepository.save(order);
            return order.getId();
        });
        // Connection is already released!
        
        // Step 2: External API (no connection occupied)
        String trackingNumber = shippingApi.createShipment(order);
        
        // Step 3: Another external API (no connection occupied)
        notificationService.notifyCustomer(order);
        
        // Step 4: Update final status (new short transaction)
        txTemplate.executeWithoutResult(status -> {
            Order updated = orderRepository.findById(orderId).orElseThrow();
            updated.setTrackingNumber(trackingNumber);
            updated.setStatus("SHIPPED");
        });
    }
}

When to Use Each Approach

Use @Transactional (Declarative) when:

  • Simple CRUD operations
  • No external API calls
  • You don't need complex conditional control
  • You want to keep code clean and readable

Use TransactionTemplate (Programmatic) when:

  • You need to mix DB operations with external I/O
  • Conditional logic determines rollback
  • Different transaction configurations in the same service
  • You need to return values from the transaction

Use PlatformTransactionManager when:

  • You need total control over lifecycle
  • Multiple transactions in one method
  • Integration with non-standard transaction systems
  • Detailed transaction logging or auditing

Conclusion

Programmatic transactions give you the control that @Transactional cannot offer. TransactionTemplate is your best ally for most cases where you need to separate database operations from external calls, thus avoiding connection pool exhaustion.

Remember: The goal is not to replace @Transactional, but to complement it when the constraints of the declarative approach limit your design.


References and Additional Resources

Official Documentation

  • Spring Framework - Programmatic Transaction Management: Complete guide from the Spring team on programmatic transaction management. docs.spring.io
  • Spring Data Access Documentation: Official documentation on data access and transactions. docs.spring.io
  • Vlad Mihalcea - Spring Transaction and Connection Management: Deep analysis of how Spring handles database connections and transactions, including lazy connection acquisition optimizations. vladmihalcea.com
  • Baeldung - Programmatic Transaction Management in Spring: Practical tutorial with TransactionTemplate and PlatformTransactionManager examples. baeldung.com
  • Marco Behler - Spring Transaction Management @Transactional In-Depth: Detailed guide on the internal workings of transactions in Spring. marcobehler.com

Additional Best Practices

  1. Configure auto-commit=false in your connection pool to enable lazy connection acquisition
  2. Set hibernate.connection.provider_disables_autocommit=true when using Hibernate
  3. Consider DELAYED_ACQUISITION_AND_RELEASE_AFTER_TRANSACTION to maximize connection reuse
  4. Design your service layer so transactional methods are called as late as possible in execution