Spring Transactions Part 2: Mastering @Transactional Parameters

This is Part 2 of a 4-part series on Spring Transactions:
In Part 1, we explored how Spring transactions work internally—the AOP proxies, transaction interceptors, and the lifecycle of a transaction. Now it's time to master the configuration options that give you fine-grained control over transaction behavior.
The @Transactional annotation accepts several parameters that control how transactions behave. Understanding each parameter is crucial for building robust applications.
Quick Recap: Part 1 Key Points
Before diving into parameters, let's recall what we learned:
- Spring uses AOP proxies to intercept
@Transactionalmethod calls - The TransactionInterceptor manages the transaction lifecycle
- PlatformTransactionManager abstracts different transaction APIs (JDBC, JPA, JTA)
- Transactions automatically commit on success and rollback on exceptions
Now, let's configure this behavior!
@Transactional Parameters Overview
Here's a quick overview of all available parameters:
@Transactional(
propagation = Propagation.REQUIRED, // Transaction propagation behavior
isolation = Isolation.DEFAULT, // Isolation level
timeout = 30, // Timeout in seconds
readOnly = false, // Read-only hint
rollbackFor = Exception.class, // Exceptions that trigger rollback
noRollbackFor = ValidationException.class // Exceptions that don't trigger rollback
)
public void yourMethod() { }Let's explore each parameter in detail.
1. Propagation Behaviors
Default: Propagation.REQUIRED
Propagation behaviors define what happens when a @Transactional method calls another @Transactional method. This is one of Spring's most powerful yet misunderstood features.
REQUIRED (Default)
@Transactional(propagation = Propagation.REQUIRED)- If a transaction exists: Join it
- If no transaction exists: Create a new one
Use case: Most common scenario—group operations within the same transaction.
@Service
public class OrderService {
@Transactional(propagation = Propagation.REQUIRED)
public void createOrder(Order order) {
orderRepository.save(order);
inventoryService.updateStock(order); // Joins the same transaction
paymentService.processPayment(order); // Joins the same transaction
}
}REQUIRES_NEW
@Transactional(propagation = Propagation.REQUIRES_NEW)- Always: Creates a new transaction
- If a transaction exists: Suspends the existing transaction
Use case: Audit logging that should commit even if the main transaction rolls back:
@Transactional
public void transferMoney(Account from, Account to, BigDecimal amount) {
// Main transaction
accountService.debit(from, amount);
accountService.credit(to, amount);
// This ALWAYS commits, even if transfer fails
auditLogService.logTransfer(from, to, amount);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logTransfer(Account from, Account to, BigDecimal amount) {
auditRepository.save(new AuditLog(from, to, amount));
}NESTED
@Transactional(propagation = Propagation.NESTED)- If a transaction exists: Creates a nested transaction (savepoint)
- If no transaction exists: Creates a new one
Use case: Partial rollback capability—nested transaction can roll back without affecting the outer transaction:
@Transactional
public void processBatch(List<Item> items) {
for (Item item : items) {
try {
// Each item processes in a nested transaction
// If one fails, only that item rolls back
processItem(item);
} catch (Exception e) {
// Log and continue with next item
logger.error("Failed to process item", e);
}
}
}
@Transactional(propagation = Propagation.NESTED)
public void processItem(Item item) {
// ... business logic
}Database Support: Not all databases support nested transactions. JDBC 3.0 savepoints are required. Check your database documentation.
SUPPORTS
@Transactional(propagation = Propagation.SUPPORTS)- If a transaction exists: Join it
- If no transaction exists: Execute non-transactionally
Use case: Read operations that can benefit from a transaction but don't require one:
@Transactional(propagation = Propagation.SUPPORTS, readOnly = true)
public List<Account> getActiveAccounts() {
return accountRepository.findByActiveTrue();
}NOT_SUPPORTED
@Transactional(propagation = Propagation.NOT_SUPPORTED)- Always: Execute non-transactionally
- If a transaction exists: Suspends it
Use case: Operations that should never be part of a transaction, like reporting queries or non-transactional data access:
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public Report generateComplexReport() {
// Executes without a transaction, even if called from a transactional method
return reportRepository.runComplexQuery();
}MANDATORY
@Transactional(propagation = Propagation.MANDATORY)- If a transaction exists: Join it
- If no transaction exists: Throw an exception
Use case: Operations that MUST run within a transaction, enforced by the framework:
@Transactional(propagation = Propagation.MANDATORY)
public void performCriticalOperation(Data data) {
// Will throw IllegalTransactionStateException if no transaction exists
criticalDataRepository.save(data);
}NEVER
@Transactional(propagation = Propagation.NEVER)- If a transaction exists: Throw an exception
- If no transaction exists: Execute non-transactionally
Use case: Operations that should never be called from within a transaction:
@Transactional(propagation = Propagation.NEVER)
public void exportDataToFile(File file) {
// Will throw IllegalTransactionStateException if called within a transaction
dataExporter.export(file);
}Propagation Decision Tree
2. Isolation Levels
Default: Isolation.DEFAULT (uses the database's default isolation level)
Isolation levels control how concurrent transactions interact with each other. They determine what changes made by other transactions are visible within your transaction.
The Isolation Levels
DEFAULT
Uses the database's default isolation level. For most databases, this is READ_COMMITTED.
READ_UNCOMMITTED
The lowest isolation level. Transactions can see uncommitted changes from other transactions.
Risks: Dirty reads, non-repeatable reads, phantom reads Use case: Rarely used—only when absolute performance is critical and data accuracy isn't critical
READ_COMMITTED
A transaction can only see committed changes from other transactions.
Risks: Non-repeatable reads, phantom reads Use case: Good default for most applications
REPEATABLE_READ
Guarantees that if you read a row multiple times, you'll get the same values.
Risks: Phantom reads (new rows appearing) Use case: When you need consistency within a transaction for data you've already read
SERIALIZABLE
The highest isolation level. Transactions are completely isolated from each other.
Risks: None (fully ACID compliant) Use case: Critical operations where absolute consistency is required (e.g., financial transactions)
Isolation Level Comparison
Practical Examples
// High consistency for financial operations
@Transactional(isolation = Isolation.SERIALIZABLE)
public void transferCriticalFunds(Account from, Account to, BigDecimal amount) {
// This transaction has full isolation
accountRepository.debit(from, amount);
accountRepository.credit(to, amount);
}
// Standard read operations
@Transactional(isolation = Isolation.READ_COMMITTED, readOnly = true)
public List<Account> getAccounts() {
return accountRepository.findAll();
}3. Timeout
Default: Uses the default timeout of the underlying transaction system
Defines the time (in seconds) before the transaction is automatically rolled back. Useful for preventing long-running transactions from holding database locks.
@Transactional(timeout = 5) // 5 seconds max
public void processTimeSensitiveOperation(Data data) {
// Will roll back after 5 seconds if not completed
}Timeout Configuration Strategies
You can configure timeouts at multiple levels:
// Method-level timeout
@Transactional(timeout = 5)
public void processPayment(Payment payment) { }
// Or use default timeout in application.yml
// spring:
// transaction:
// default-timeout: 10Choosing a Timeout: Set timeout based on your operation's expected duration plus a safety margin. Monitor production to identify slow transactions.
4. ReadOnly
Default: false
A hint to the database that the transaction will only read data. This can enable optimizations:
@Transactional(readOnly = true)
public List<Account> getAllAccounts() {
return accountRepository.findAll();
}Benefits of ReadOnly Transactions
- Performance: Database skips dirty checks and can optimize queries
- Connection Pooling: Some connection pools route read-only transactions to read replicas
- Intent: Clearly signals that the method doesn't modify data
Performance Tip: Always mark read-only operations with readOnly = true. This allows the database to skip dirty checks and can significantly improve query performance.
5. Rollback Rules
Default: Rolls back on RuntimeException, doesn't roll back on checked exceptions
Specify which exceptions should trigger a rollback:
// Rollback on custom checked exception
@Transactional(rollbackFor = InsufficientFundsException.class)
public void withdraw(Account account, BigDecimal amount) throws InsufficientFundsException {
// ...
}
// Don't rollback even on runtime exception
@Transactional(noRollbackFor = DataValidationException.class)
public void processData(Data data) {
// ...
}
// Multiple exceptions
@Transactional(
rollbackFor = {
PaymentFailedException.class,
InventoryUnavailableException.class
},
noRollbackFor = {
ValidationException.class
}
)
public Order createOrder(OrderRequest request) {
// ...
}Rollback Decision Flow
Putting It All Together
Here's a comprehensive example using multiple parameters:
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentRepository paymentRepository;
private final AuditService auditService;
@Transactional(
propagation = Propagation.REQUIRED,
isolation = Isolation.READ_COMMITTED,
timeout = 10,
readOnly = false,
rollbackFor = {
PaymentFailedException.class,
InsufficientFundsException.class
},
noRollbackFor = {
ValidationException.class
}
)
public PaymentResult processPayment(PaymentRequest request) {
// Validation exception won't roll back
if (!request.isValid()) {
throw new ValidationException("Invalid payment request");
}
Payment payment = paymentRepository.save(new Payment(request));
// Audit log in separate transaction (always commits)
auditService.logPaymentAttempt(payment);
return new PaymentResult(payment.getId(), PaymentStatus.PROCESSED);
}
}What's Next?
Now that you've mastered all the @Transactional parameters, it's time to learn about the common pitfalls that even experienced developers fall into.
In Part 3, we'll explore:
- Why
@Transactionaldoesn't work on private methods - The self-invocation proxy problem and solutions
- Exception handling mistakes
- Why you shouldn't mix programmatic and declarative transactions
- Lazy loading exceptions and how to avoid them
- Real-world before/after code examples
Continue to Part 3: Common Pitfalls and Best Practices →
Key Takeaways from Part 2:
- Propagation controls how transactions interact (7 behaviors available)
- Isolation controls concurrent transaction visibility (5 levels available)
- Timeout prevents long-running transactions from holding locks
- ReadOnly enables database optimizations for read operations
- Rollback rules give fine-grained control over exception handling
- Use
REQUIRED(propagation) andREAD_COMMITTED(isolation) as defaults, adjust based on needs
Tags
Related Posts

Spring Transactions Part 1: Understanding How It Works Under the Hood
Learn the internals of Spring's @Transactional annotation - how AOP proxies work, the transaction lifecycle, and the magic behind declarative transaction management.

Spring Transactions Part 3: Common Pitfalls and Best Practices
Learn the common pitfalls even experienced developers make with @Transactional and the best practices that separate novices from experts.

Spring Transactions Part 4: Production Optimization and Performance
Learn how to optimize Spring transactions for production environments with connection pool tuning, monitoring strategies, and distributed transaction patterns.