Spring Programmatic Transaction Management: A Practical Guide for Beginners
@Transactional is convenient. Slap it on a method, and Spring handles the transaction for you. But convenience has a price, and that price is control. When your method mixes database calls with slow external APIs, @Transactional becomes a liability. Here's how and when to take manual control.
Why programmatic transactions
The connection pool trap
Here's a pattern I've seen in production more times than I care to count:
@Transactional
public void processPayment(PaymentRequest request) {
savePaymentRequest(request); // DB
callPaymentProviderApi(request); // External API (slow)
updatePaymentState(request); // DB
saveAuditHistory(request); // DB
}Here's what happens when Spring opens that transaction:
@Transactional grabs a connection from the pool and holds it for the entire method. That includes the time spent waiting on the external API call. If the API takes 5-10 seconds to respond, your database connection sits idle for that entire window. Under enough concurrent requests, you exhaust every connection in the pool just waiting on slow APIs.
Never mix database operations with external API calls inside the same transaction.
TransactionTemplate: the clean option
TransactionTemplate gives you a callback API where you control exactly when the transaction starts and ends. It's the approach I'd reach for first.
Setup
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);
}
}Spring Boot auto-configures a
PlatformTransactionManager. Just inject it.
Returning a value from a transaction
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 just happened: a transaction opens before the lambda runs, executes the code, commits on success, rolls back on any exception, and returns whatever the lambda produced.
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());
}
}Manual rollback with setRollbackOnly
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();
});
}When you don't need a 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
}
});
}Multiple templates, different settings
Not every transaction needs the same isolation level or timeout. Define multiple beans:
@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;
}
}What you can configure
setIsolationLevel() takes constants like ISOLATION_READ_UNCOMMITTED, ISOLATION_READ_COMMITTED, ISOLATION_REPEATABLE_READ, or ISOLATION_SERIALIZABLE. setPropagationBehavior() accepts PROPAGATION_REQUIRED (join existing or create new), PROPAGATION_REQUIRES_NEW (always a fresh transaction), or PROPAGATION_NESTED (savepoint-based nesting). setTimeout() takes seconds. setReadOnly() is a hint the driver can use for query optimization.
PlatformTransactionManager: the raw API
Underneath everything sits PlatformTransactionManager. It's the engine that @Transactional and TransactionTemplate are built on. You rarely need it directly, but when you do, it gives you precise control.
Full 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);
}
}
}TransactionTemplate handles the boilerplate: commit on success, rollback on exception, all wrapped in a lambda. With PlatformTransactionManager, you call getTransaction(), commit(), and rollback() yourself. More verbose, but you get exact control over the lifecycle. For 95% of cases, the template is the right call. Use the raw manager when you need multiple transactions in one method or integration with a non-standard system.
Real-world example: separating DB from external calls
Here's the problematic version:
@Transactional
public void processOrder(Order order) {
orderRepository.save(order); // DB
shippingApi.createShipment(order); // Slow API
notificationService.notifyCustomer(order); // Slow API
}Fixed 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");
});
}
}Which approach when
Stick with @Transactional for straightforward CRUD where the whole method is one logical unit of database work. No external calls, no conditional rollback logic.
Switch to TransactionTemplate the moment you're calling an external API, sending an email, or doing anything that blocks outside the database. It lets you scope the transaction to just the database calls and release the connection before the slow stuff begins. Also useful when different methods in the same service need different isolation levels or propagation behavior.
Reach for PlatformTransactionManager directly when you need multiple separate transactions in one method, or when you're integrating with a transaction system that doesn't fit Spring's standard model.
TransactionTemplate is the tool I reach for whenever a service method does anything beyond pure database work. It keeps transactions tight, connections released, and pools healthy. @Transactional still has its place. This isn't about replacing it, it's about knowing when it's the wrong tool.
Further reading
The official Spring docs cover programmatic transactions in detail at docs.spring.io. Spring's broader data access reference lives at docs.spring.io.
Vlad Mihalcea's writeup on Spring transaction and connection management goes deep on lazy connection acquisition and is worth reading. Baeldung has a solid practical tutorial on programmatic transactions with working examples. Marco Behler's in-depth @Transactional guide explains what actually happens under the hood.
A few pool-level settings worth checking. Set auto-commit=false on your connection pool to enable lazy connection acquisition. Flip on hibernate.connection.provider_disables_autocommit=true if you're using Hibernate. Look into DELAYED_ACQUISITION_AND_RELEASE_AFTER_TRANSACTION to maximize connection reuse. And design your service layer so transactional code runs as late as possible in the call chain.