Skip to main content

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:

  1. ✅ Understand ACID properties
  2. ✅ Use @Transactional correctly
  3. ✅ Choose the right propagation level
  4. ✅ Understand isolation levels
  5. ✅ Handle rollbacks correctly
  6. ✅ 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​

PropagationDescription
REQUIREDReuses or creates (default)
REQUIRES_NEWAlways new transaction
SUPPORTSTransaction if already existing, otherwise non-transactional
MANDATORYTransaction required (exception otherwise)
NOT_SUPPORTEDSuspends the existing transaction
NEVERException if transaction exists
NESTEDNested 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​

LevelDirty ReadNon-RepeatablePhantom
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) { }
caution

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


⬅️ Back to Module 7