Mastering Programmatic Transactions in Spring: TransactionTemplate & PlatformTransactionManager
Go beyond @Transactional.
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
- ✓Spring Boot 3.x or Spring Framework 6.x project
- ✓Basic understanding of Spring's @Transactional annotation
- ✓Familiarity with dependency injection and Spring configuration
- ✓A running database (H2 for testing, PostgreSQL/MySQL for production)
- Use TransactionTemplate for most programmatic transaction needs; it's simpler and less error-prone.
- Fall back to PlatformTransactionManager directly only when you need to manage transactions across multiple threads or non-standard scopes.
- Always set explicit rollback rules; never rely on defaults for checked exceptions.
- Watch out for transaction isolation levels and propagation behaviors; they can silently corrupt data if misconfigured.
- Test transaction rollback behavior with actual database calls, not mocks.
Think of a transaction like a bank transfer: you want to take money from one account and add it to another as a single, all-or-nothing operation. @Transactional is like an automatic teller that handles the whole thing for you. But sometimes you need to manually control each step — like when you're moving money between multiple banks and need to decide on the spot whether to commit or roll back. TransactionTemplate is like a programmable teller that follows your script, while PlatformTransactionManager is the vault itself — you can open, close, and manage the vault directly if you need to.
Most Spring developers start with @Transactional. It's clean, declarative, and works 90% of the time. But that 10% — when you need to dynamically decide to commit or rollback based on runtime conditions, or when you're dealing with multiple data sources, or when you need to manage transaction boundaries across async operations — that's where @Transactional falls apart. I've seen teams spend days debugging transaction issues that could have been solved in minutes with programmatic transaction management.
Enter TransactionTemplate and PlatformTransactionManager. These are the tools Spring provides for fine-grained, programmatic transaction control. TransactionTemplate is a callback-based template that handles transaction lifecycle for you. PlatformTransactionManager is the lower-level SPI that actually manages transactions against a specific resource (like a JDBC DataSource or JPA EntityManagerFactory).
In this article, I'll show you when and how to use each, with real code examples from payment processing systems, batch jobs, and multi-tenant applications. We'll also look at a production incident where a naive @Transactional caused a data corruption that cost a fintech startup thousands. By the end, you'll know exactly when to ditch the annotation and take manual control.
Why Go Programmatic? The Case Against @Transactional
Let's be honest: @Transactional is great for simple CRUD operations. But in real-world enterprise applications, you often need more control. Here's when I ditch the annotation:
- Dynamic Rollback Decisions: You need to commit or rollback based on a business rule, not just an exception. For example, in a payment reconciliation system, you might want to rollback a transaction if the external payment gateway returns a specific error code, but commit if it's a temporary network glitch.
- Multiple Transaction Boundaries in One Method: You have a loop that processes many items, and each item should be its own transaction. @Transactional on the method would wrap the entire loop in one transaction, which is almost never what you want.
- Non-Standard Transaction Propagation: You need to suspend a transaction, run some code without a transaction, and then resume. Or you need to run multiple transactions sequentially within the same method.
- Testing and Mocking: When writing integration tests, you often need to manually control transaction boundaries without relying on Spring's AOP proxies.
I once worked on a SaaS billing system where we processed thousands of subscription renewals every hour. Each renewal involved charging a credit card, updating the database, and sending a notification. If any step failed, we wanted to rollback only that renewal, not the entire batch. @Transactional on the batch method would have been disastrous. We used TransactionTemplate inside a loop, and it worked flawlessly.
Here's the hard truth: if you're using @Transactional on a method that contains a loop, a conditional, or a try-catch that swallows exceptions, you're probably doing it wrong. Programmatic transactions give you the control you need without the magic.
TransactionTemplate: The Workhorse of Programmatic Transactions
TransactionTemplate is my go-to for programmatic transaction management. It's part of the Spring transaction abstraction and provides a simple callback-based API. Here's how it works:
You create a TransactionTemplate with a PlatformTransactionManager (usually injected via constructor). Then you call execute() with a TransactionCallback that contains your business logic. The template handles begin, commit, and rollback for you.
- Propagation behavior: Default is REQUIRED. Use REQUIRES_NEW to suspend any existing transaction and start a new one.
- Isolation level: Default is the database's default. Set it explicitly if you need repeatable reads or serializable.
- Timeout: Default is -1 (no timeout). Always set a timeout in production to avoid indefinite locks.
- Rollback rules: By default, only unchecked exceptions trigger rollback. Use setRollbackRules() to specify checked exceptions that should cause rollback.
Here's a real example from a payment processing service. We need to charge a customer's card, update the subscription, and log the transaction. If any step fails, we want to rollback everything:
```java @Service public class PaymentService { private final TransactionTemplate txTemplate;
public PaymentService(PlatformTransactionManager txManager) { this.txTemplate = new TransactionTemplate(txManager); this.txTemplate.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); this.txTemplate.setTimeout(10); // Rollback on checked PaymentException too this.txTemplate.setRollbackOnCommitFailure(true); }
public PaymentResult chargeCustomer(Customer customer, BigDecimal amount) { return txTemplate.execute(status -> { try { // 1. Charge credit card via external gateway String chargeId = paymentGateway.charge(customer.getCardToken(), amount); // 2. Update subscription in database subscriptionService.renew(customer.getId(), amount); // 3. Log transaction transactionLogService.log(customer.getId(), chargeId, amount); return new PaymentResult(true, chargeId); } catch (PaymentGatewayException e) { // Business decision: rollback for certain errors if (e.isRetryable()) { status.setRollbackOnly(); throw e; // rethrow to trigger rollback } else { // Non-retryable: still rollback but log differently status.setRollbackOnly(); throw new NonRetryablePaymentException(e); } } }); } } ```
Notice how we have full control over when to rollback. With @Transactional, we'd have to wrap everything in a try-catch and rethrow a runtime exception, which is less expressive.
PlatformTransactionManager: When You Need Full Control
TransactionTemplate is great for most cases, but sometimes you need to go lower level. PlatformTransactionManager is the SPI that manages transactions directly. You call getTransaction(), then commit() or rollback().
When would you need this? - Multiple threads: If you need to share a transaction across threads (e.g., fork-join), you can't use TransactionTemplate because it's tied to a single thread. You'll need to get the transaction object and pass it around. - Non-standard transaction lifecycle: For example, you want to begin a transaction, do some work, then conditionally commit or rollback based on an external event. - Custom resource management: If you're integrating with a non-Spring resource (e.g., a legacy JDBC connection), you might need to manage the transaction manually.
Here's an example of manual transaction management for a batch job that processes items in parallel:
```java @Service public class ParallelBatchProcessor { private final PlatformTransactionManager txManager; private final TransactionDefinition txDef;
public ParallelBatchProcessor(PlatformTransactionManager txManager) { this.txManager = txManager; this.txDef = new DefaultTransactionDefinition(); ((DefaultTransactionDefinition) this.txDef).setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); ((DefaultTransactionDefinition) this.txDef).setTimeout(30); }
public void processInParallel(List<Item> items) { List<CompletableFuture<Void>> futures = items.stream() .map(item -> CompletableFuture.runAsync(() -> processItemInTransaction(item))) .collect(Collectors.toList()); CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); }
private void processItemInTransaction(Item item) { TransactionStatus status = txManager.getTransaction(txDef); try { // Business logic processItem(item); txManager.commit(status); } catch (Exception e) { txManager.rollback(status); throw e; // rethrow after rollback } } } ```
Notice that each thread gets its own transaction. This is impossible with @Transactional because the annotation creates a transaction bound to the calling thread. With PlatformTransactionManager, you have full control.
However, with great power comes great responsibility. You must ensure that every code path calls either commit() or rollback(). Forgetting to do so will leave the transaction hanging, potentially locking resources indefinitely.
What the Official Docs Won't Tell You
I've been using Spring's programmatic transaction support for over a decade. Here are the gotchas that the official documentation glosses over:
1. TransactionTemplate and Thread Safety TransactionTemplate is thread-safe after configuration. However, if you change its properties (e.g., timeout) after it's been used, you're asking for trouble. Always configure it once at construction time and treat it as immutable. I've seen teams create a new TransactionTemplate for every request, which defeats the purpose of reuse and can cause memory leaks.
2. PlatformTransactionManager.getTransaction() Does Not Always Create a New Transaction This is a huge one. The behavior depends on the propagation setting. If you call getTransaction() with PROPAGATION_REQUIRED inside an existing transaction, it will join that transaction. If you want a new transaction, you must use PROPAGATION_REQUIRES_NEW. Many developers assume getTransaction() always starts a new transaction, leading to unexpected sharing.
3. Checked Exceptions and Rollback By default, both @Transactional and TransactionTemplate only rollback for unchecked exceptions (RuntimeException and Error). Checked exceptions do NOT trigger rollback. This is a common source of data corruption. Always set explicit rollback rules for checked exceptions using rollbackFor or setRollbackRules().
4. TransactionTemplate.execute() Does Not Propagate Checked Exceptions The TransactionCallback interface's doInTransaction() method throws Exception, but TransactionTemplate.execute() only propagates unchecked exceptions. If your callback throws a checked exception, it gets wrapped in an UndeclaredThrowableException. To avoid this, catch checked exceptions inside the callback and either convert them to unchecked exceptions or handle them appropriately.
5. Nested Transactions and Savepoints Spring supports savepoints for nested transactions if your underlying resource (e.g., JDBC) supports them. But beware: savepoints are only available with PROPAGATION_NESTED, and they rely on the JDBC driver's support. Not all databases support savepoints (e.g., some older MySQL versions). Test thoroughly.
6. Transaction Synchronization If you register transaction synchronization callbacks (e.g., via TransactionSynchronizationManager), they run after commit or rollback. But if you're using PlatformTransactionManager directly, you must ensure you call triggerAfterCompletion() manually. TransactionTemplate does this automatically, but raw usage does not.
Transaction Propagation: The Silent Killer
Transaction propagation determines how transactions behave when called within an existing transaction. Spring supports seven propagation behaviors, but most developers only use REQUIRED and REQUIRES_NEW. Here's what you need to know:
- REQUIRED (default): Joins the current transaction if one exists; creates a new one if not. This is what @Transactional uses by default.
- REQUIRES_NEW: Suspends the current transaction (if any) and creates a new one. The suspended transaction resumes after the new one completes.
- NESTED: Uses a savepoint within the current transaction. If the inner transaction rolls back, only the savepoint is rolled back, not the entire outer transaction. This is tricky and database-dependent.
- MANDATORY: Throws an exception if no current transaction exists.
- NEVER: Throws an exception if a current transaction exists.
- SUPPORTS: If a transaction exists, joins it; otherwise, runs without a transaction.
- NOT_SUPPORTED: Suspends any current transaction and runs without one.
The most common mistake is using REQUIRED when you should use REQUIRES_NEW. For example, in an audit logging service, you typically want each log entry to be persisted independently, regardless of the caller's transaction. If you use REQUIRED, the log entry will be part of the caller's transaction and will rollback if the caller rolls back. That's usually not what you want.
Here's a real production scenario: We had a service that processed orders. Each order processing involved updating inventory, charging the customer, and sending a confirmation email. The email sending was done in a separate transaction because we didn't want a failed email to rollback the order. We used REQUIRES_NEW for the email transaction. But the developer mistakenly used REQUIRED, and when the email service was down, the entire order rolled back, causing lost revenue. The fix was simple: change propagation to REQUIRES_NEW.
Testing Programmatic Transactions: Don't Skip This
Testing transactions is notoriously tricky. With @Transactional, Spring's test support can automatically rollback after each test. But with programmatic transactions, you're on your own. Here's how I approach testing:
Unit Testing TransactionTemplate You can mock PlatformTransactionManager and verify that commit/rollback are called correctly. However, this doesn't test the actual transaction behavior. For that, you need integration tests.
Integration Testing Use an in-memory database (H2) or a lightweight test container (Testcontainers). Create a real PlatformTransactionManager bean and inject it into your service. Then, write tests that actually perform database operations and verify the state after commit or rollback.
- Transaction commits when no exception is thrown.
- Transaction rolls back on unchecked exceptions.
- Transaction rolls back on checked exceptions if configured.
- Transaction timeout triggers rollback.
- Nested transactions and savepoints work correctly.
Here's an example using Spring Boot test:
```java @SpringBootTest class PaymentServiceTest { @Autowired private PaymentService paymentService; @Autowired private JdbcTemplate jdbcTemplate;
@Test void shouldRollbackOnPaymentException() { assertThrows(PaymentGatewayException.class, () -> { paymentService.chargeCustomer(new Customer("test"), BigDecimal.TEN); }); // Verify no data was persisted Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM transactions WHERE customer_id = ?", Integer.class, "test"); assertEquals(0, count); } } ```
Notice we use JdbcTemplate to directly query the database and verify the state. This is far more reliable than relying on mocks.
The Midnight Transaction That Lost Invoices
- @Transactional on a method that loops over operations is a red flag — each iteration should typically be its own transaction.
- Never swallow exceptions inside a transaction boundary unless you absolutely know what you're doing. Log and rethrow, or use a savepoint.
- Always test transaction rollback behavior with realistic data volumes and concurrent access patterns.
- Use TransactionTemplate when you need fine-grained control over transaction boundaries inside loops or conditional logic.
- Monitor transaction metrics (commit/rollback counts) in production to catch silent rollbacks early.
Enable DEBUG logging for org.springframework.transactionCheck logs for 'Completing transaction' with commit or rollback| File | Command / Code | Purpose |
|---|---|---|
| ProgrammaticVsDeclarative.java | @Service | Why Go Programmatic? The Case Against @Transactional |
| PaymentService.java | @Service | TransactionTemplate |
| ParallelBatchProcessor.java | @Service | PlatformTransactionManager |
| CheckedExceptionRollback.java | transactionTemplate.execute(status -> { | What the Official Docs Won't Tell You |
| PropagationExample.java | @Service | Transaction Propagation |
| PaymentServiceTest.java | @SpringBootTest | Testing Programmatic Transactions |
Key takeaways
Interview Questions on This Topic
Explain the difference between TransactionTemplate and @Transactional. When would you choose one over the other?
Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
That's Hibernate & JPA. Mark it forged?
7 min read · try the examples if you haven't