Chapter 2: Transaction Management (@Transactional)
Introduction
Imagine a bank transfer: you debit €100 from account A and credit €100 to account B. What happens if the credit fails after the debit? Without a transaction, the €100 disappears!
Transactions ensure that your data operations are reliable and consistent.
🎯 Chapter Objectives
By the end of this chapter, you will know how to:
- ✅ Understand ACID properties
- ✅ Use @Transactional correctly
- ✅ Choose the right propagation level
- ✅ Understand isolation levels
- ✅ Handle rollbacks correctly
- ✅ Avoid common pitfalls
1. What is a Transaction?
Definition
A transaction is a set of operations that must all succeed or all fail.
Concrete Example: Bank Transfer
// WITHOUT transaction (DANGEROUS!)
public void transfer(Long accountA, Long accountB, BigDecimal amount) {
accountRepo.debit(accountA, amount); // ← Succeeded: -€100
// 💥 ERROR HERE (network, exception, etc.)
accountRepo.credit(accountB, amount); // ← Never executed!
// Result: €100 have DISAPPEARED! 💀
}
// WITH transaction (SECURE)
@Transactional
public void transfer(Long accountA, Long accountB, BigDecimal amount) {
accountRepo.debit(accountA, amount); // ← Within the transaction
// 💥 ERROR HERE
accountRepo.credit(accountB, amount); // ← Never executed
// Result: Automatic ROLLBACK, the debit is UNDONE ✓
}
2. ACID Properties
Each transaction adheres to 4 fundamental properties:
A - Atomicity
"All or nothing"
✅ All operations succeed → COMMIT
❌ One operation fails → ROLLBACK everything
C - Consistency
"The database remains in a valid state"
Before: Account A = €500, Account B = €300, Total = €800
After: Account A = €400, Account B = €400, Total = €800
Money doesn't disappear or appear magically.
I - Isolation
"Transactions don't see each other"
Transaction 1: Reads balance = €500
Transaction 2: Modifies balance = €400
Transaction 1: Reads again = €500 (or €400 depending on isolation)
D - Durability
"Once COMMIT, it's final"
Transaction COMMIT ✓
→ Even if the server crashes afterward, the data is saved.
3. @Transactional in Detail
Basic Usage
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
@Transactional
public void createOrder(Order order) {
// All these operations are in ONE transaction
orderRepo.save(order);
inventoryService.decreaseStock(order.getItems());
paymentService.processPayment(order.getTotal());
emailService.sendConfirmation(order);
}
}
How It Works
Spring uses a proxy around your class:
Where to Place @Transactional?
// ✅ RECOMMENDED: On the service method
@Service
public class UserService {
@Transactional
public void createUser(User user) {
// ...
}
}
// ✅ OK: On the class (all public methods)
@Service
@Transactional
public class UserService {
public void createUser(User user) { /* transactional */ }
public void updateUser(User user) { /* transactional */ }
}
// ❌ DOES NOT WORK: On private methods
@Service
public class UserService {
@Transactional // ← IGNORED!
private void internalOperation() {
// The proxy cannot intercept private methods
}
}
4. Propagation Levels
Propagation defines how a transaction behaves when it calls another transactional method.
REQUIRED (Default)
"Reuses the existing transaction, or creates a new one"
@Transactional(propagation = Propagation.REQUIRED) // Default
public void methodA() {
methodB(); // ← Reuses methodA's transaction
}
@Transactional
public void methodB() {
// Same transaction as methodA
}
methodA() BEGIN TRANSACTION
├── methodA operations
├── methodB() [same transaction]
│ └── methodB operations
└── COMMIT (or global ROLLBACK)
REQUIRES_NEW
"Always create a new transaction"
@Transactional
public void methodA() {
// Transaction 1
repo.save(entity);
loggingService.logAction("action"); // ← Transaction 2 (independent)
throw new RuntimeException(); // ← Rollback Transaction 1
// But the log is PRESERVED (Transaction 2 committed)
}
@Service
public class LoggingService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAction(String action) {
// New independent transaction
logRepo.save(new Log(action));
}
}
methodA() BEGIN TRANSACTION 1
├── save entity
├── logAction() BEGIN TRANSACTION 2
│ ├── save log
│ └── COMMIT TRANSACTION 2 ✓
├── throw exception
└── ROLLBACK TRANSACTION 1 (entity undone, log preserved)
Other Propagations
| Propagation | Description |
|---|---|
| REQUIRED | Reuses or creates (default) |
| REQUIRES_NEW | Always new transaction |
| SUPPORTS | Transaction if already existing, otherwise non-transactional |
| MANDATORY | Transaction required (exception otherwise) |
| NOT_SUPPORTED | Suspends the existing transaction |
| NEVER | Exception if transaction exists |
| NESTED | Nested transaction (savepoints) |
5. Isolation Levels
Isolation defines how transactions see changes from other transactions.
Isolation Problems
1. Dirty Read
Transaction A: UPDATE balance = 400 (not yet COMMIT)
Transaction B: SELECT balance → 400 (reads an uncommitted value!)
Transaction A: ROLLBACK
Result: B read a value that no longer exists
2. Non-Repeatable Read
Transaction A: SELECT balance → 500
Transaction B: UPDATE balance = 400, COMMIT
Transaction A: SELECT balance → 400 (different value!)
3. Phantom Read
Transaction A: SELECT COUNT(*) WHERE status='ACTIVE' → 10
Transaction B: INSERT INTO users (status='ACTIVE'), COMMIT
Transaction A: SELECT COUNT(*) WHERE status='ACTIVE' → 11 (new record!)
Isolation Levels
| Level | Dirty Read | Non-Repeatable | Phantom |
|---|---|---|---|
| READ_UNCOMMITTED | ❌ Possible | ❌ Possible | ❌ Possible |
| READ_COMMITTED | ✅ Prevented | ❌ Possible | ❌ Possible |
| REPEATABLE_READ | ✅ Prevented | ✅ Prevented | ❌ Possible |
| SERIALIZABLE | ✅ Prevented | ✅ Prevented | ✅ Prevented |
Usage
// Default: READ_COMMITTED (depending on the DB)
@Transactional
public void normalOperation() { }
// For financial reports: SERIALIZABLE
@Transactional(isolation = Isolation.SERIALIZABLE)
public BigDecimal calculateTotalBalance() { }
// For simple reads: READ_COMMITTED is enough
@Transactional(isolation = Isolation.READ_COMMITTED)
public User getUser(Long id) { }
SERIALIZABLE is the safest but the most slow. Use it only when truly necessary!
6. Rollback and Exceptions
Default Behavior
@Transactional
public void operation() {
repo.save(entity);
throw new RuntimeException(); // ✅ Automatic ROLLBACK
}
@Transactional
public void operation() throws IOException {
repo.save(entity);
throw new IOException(); // ❌ NO rollback! (checked exception)
}
Default Rule:
- ✅ RuntimeException (unchecked) → Rollback
- ❌ Exception (checked) → No rollback
Forcing Rollback
// Rollback on all exceptions
@Transactional(rollbackFor = Exception.class)
public void operation() throws Exception {
repo.save(entity);
throw new IOException(); // ✅ ROLLBACK now
}
// Rollback on specific exceptions
@Transactional(rollbackFor = {IOException.class, CustomException.class})
public void operation() throws Exception { }
No Rollback
// No rollback on certain exceptions
@Transactional(noRollbackFor = BusinessException.class)
public void operation() {
repo.save(entity);
throw new BusinessException("Business rule violated");
// No rollback, the entity is saved
}
7. Read-Only Transactions
Optimization for Reads
@Transactional(readOnly = true)
public List<User> getAllUsers() {
return userRepo.findAll();
}
@Transactional(readOnly = true)
public User getUser(Long id) {
return userRepo.findById(id).orElseThrow();
}
Advantages:
- Hibernate disables dirty checking (faster)
- Some DBs optimize read-only queries
- Explicit documentation of intent
At the Class Level
@Service
@Transactional(readOnly = true) // Default: read-only
public class UserService {
public List<User> getAllUsers() { } // readOnly = true
public User getUser(Long id) { } // readOnly = true
@Transactional // Override: read-write
public void createUser(User user) { }
}
8. Timeout
Limiting Transaction Duration
@Transactional(timeout = 5) // 5 seconds max
public void longOperation() {
// If it takes more than 5 seconds → TransactionTimedOutException
}
Use Cases:
- Complex queries that might block
- Protection against deadlocks
- Ensuring system responsiveness
9. Common Pitfalls
❌ Pitfall 1: Self-Invocation (Most Common!)
@Service
public class UserService {
public void methodA() {
this.methodB(); // ❌ @Transactional IGNORED!
}
@Transactional
public void methodB() {
// No transaction because called from the same class
}
}
Why? The proxy can only intercept external calls.
Solution:
@Service
public class UserService {
@Autowired
private UserService self; // Self-injection
public void methodA() {
self.methodB(); // ✅ Goes through the proxy
}
@Transactional
public void methodB() {
// Transaction active!
}
}
❌ Pitfall 2: Swallowed Exception
@Transactional
public void operation() {
try {
riskyOperation();
} catch (Exception e) {
log.error("Ignored error", e); // ❌ Swallowed exception
// The transaction doesn't know there was an error!
}
}
Solution:
@Transactional
public void operation() {
try {
riskyOperation();
} catch (Exception e) {
log.error("Error", e);
throw e; // ✅ Re-throw to trigger the rollback
}
}
❌ Pitfall 3: @Transactional on Interface
// ❌ DOES NOT WORK with CGLIB proxies (default)
public interface UserService {
@Transactional
void createUser(User user);
}
// ✅ Put the annotation on the implementation
@Service
public class UserServiceImpl implements UserService {
@Transactional
public void createUser(User user) { }
}
❌ Pitfall 4: Final Method
@Service
public class UserService {
@Transactional
public final void operation() { // ❌ CGLIB cannot create the proxy
// Transaction ignored!
}
}
Solution: Remove the final keyword.
10. Summary
Attribute Recap
@Transactional(
propagation = Propagation.REQUIRED, // Transaction behavior
isolation = Isolation.READ_COMMITTED, // Isolation level
timeout = 30, // Timeout in seconds
readOnly = false, // Read-only?
rollbackFor = Exception.class, // Rollback on which exceptions?
noRollbackFor = BusinessException.class // No rollback on which exceptions?
)
public void operation() { }
Common Configurations
// Standard create/update
@Transactional
public void createUser(User user) { }
// Optimized read
@Transactional(readOnly = true)
public User getUser(Long id) { }
// Critical operation (audit)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void auditLog(String action) { }
// Payment processing
@Transactional(
isolation = Isolation.SERIALIZABLE,
rollbackFor = Exception.class
)
public void processPayment(Payment payment) { }
Checklist
✅ Put @Transactional on service methods (not controller)
✅ Use readOnly = true for reads
✅ Understand the propagation used
✅ Ensure exceptions trigger rollback
✅ Avoid self-invocation
✅ Do not swallow exceptions
🎯 Next Step
Now that you master transactions, let's learn to version your database schema with Flyway.
➡️ Chapter 3: Schema Migration with Flyway