Managing Transactions with @Transactional and Rollback
In backend systems managing mission-critical workflows (such as banking transfers, e-commerce orders, or inventory bookings), multiple SQL statements must execute as an indivisible unit of work. If any part of the operation fails, all intermediate changes must be reverted (Rollback) to prevent data corruption. In Spring Framework, this process is managed declaratively using the @Transactional annotation.
1. Concepts and Theoretical Foundations
ACID Properties
Database transactions adhere to four essential principles:
- Atomicity: The unit of work executes completely or not at all ("all or nothing").
- Consistency: The database transitions from one valid state to another, enforcing constraints, foreign keys, and rules.
- Isolation: Concurrent transactions execute without mutual interference or reading dirty, intermediate states.
- Durability: Once committed (
COMMIT), modifications remain permanent, surviving server crashes or restarts.
How Does the Spring Transactional Proxy Operate?
Spring relies on the Proxy Pattern through Spring AOP. When you annotate a class or method with @Transactional, callers do not communicate directly with your actual bean, but with an intermediary proxy:
Step-by-Step Proxy Flow
- Web Controller / Caller: Calls the service method unaware of the transactional proxy layer.
- Spring AOP Proxy: Intercepts the invocation before it reaches your target bean.
PlatformTransactionManager: Opens a connection from the pool and disables database autocommit (SET autocommit = false).- Target Service: Executes the core business logic (debits, credits, DML operations).
- Completion Decision:
- Case A (Success): If the method completes normally, the proxy calls
commit()on the transaction manager. - Case B (Rollback): If an eligible exception is thrown (
RuntimeExceptionor classes defined inrollbackFor), the proxy callsrollback(), undoing all pending changes.
- Case A (Success): If the method completes normally, the proxy calls
Default Rollback Rules vs. rollbackFor
By default in Spring, only unchecked exceptions (subclasses of RuntimeException and Error) trigger automatic rollbacks. If your code throws a checked exception (Exception or IOException), Spring will NOT roll back changes unless explicitly configured.
To ensure checked business exceptions trigger rollbacks, declare rollbackFor:
@Transactional(rollbackFor = Exception.class)
Conversely, to prevent specific non-fatal exceptions from canceling a transaction, use noRollbackFor:
@Transactional(noRollbackFor = EmailNotificationFailedException.class)
Core @Transactional Attributes
| Attribute | Purpose | Default |
|---|---|---|
rollbackFor | Exception classes that force a ROLLBACK. | {RuntimeException.class, Error.class} |
noRollbackFor | Exception classes that must not trigger a rollback. | {} |
readOnly | Optimizes Hibernate/JPA session performance by disabling entity dirty-checking. | false |
propagation | Defines how the method behaves relative to existing transactions (e.g., REQUIRED, REQUIRES_NEW). | Propagation.REQUIRED |
isolation | Configures transaction isolation level (preventing dirty or phantom reads). | Isolation.DEFAULT |
2. Practical Guide: Step-by-Step
We will implement an atomic funds transfer: debit the source account, credit the destination account, and record the changes. If balance is insufficient or an account is blocked, the transaction rolls back cleanly.
Define Domain Business Exceptions
Create explicit custom exceptions to model business failures.
package com.icesi.bank.exception;
// Unchecked exception (triggers rollback by default)
public class InsufficientFundsException extends RuntimeException {
public InsufficientFundsException(String message) {
super(message);
}
}
package com.icesi.bank.exception;
// Checked exception (requires explicit rollbackFor configuration)
public class AccountBlockedException extends Exception {
public AccountBlockedException(String message) {
super(message);
}
}
Model the BankAccount Entity
Define the entity with balance and active status fields.
package com.icesi.bank.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "bank_accounts")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class BankAccount {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// Unique account identifier
private String accountNumber;
// Account owner name
private String ownerName;
// Current balance
private Double balance;
// Account state flag
private Boolean active;
}
Define the Spring Data Repository
Declare queries for finding accounts by account number.
package com.icesi.bank.repository;
import com.icesi.bank.model.BankAccount;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface BankAccountRepository extends JpaRepository<BankAccount, Long> {
Optional<BankAccount> findByAccountNumber(String accountNumber);
}
Implement the Transactional Service with Rollback
Annotate with @Transactional(rollbackFor = {AccountBlockedException.class, Exception.class}) to guarantee atomicity.
package com.icesi.bank.service;
import com.icesi.bank.exception.AccountBlockedException;
import com.icesi.bank.exception.InsufficientFundsException;
import com.icesi.bank.model.BankAccount;
import com.icesi.bank.repository.BankAccountRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class TransferService {
private final BankAccountRepository accountRepository;
@Autowired
public TransferService(BankAccountRepository accountRepository) {
this.accountRepository = accountRepository;
}
/**
* Executes an atomic funds transfer.
* Reverts all mutations if InsufficientFundsException or AccountBlockedException occurs.
*/
@Transactional(rollbackFor = {AccountBlockedException.class, Exception.class})
public void transferMoney(Long sourceAccountId, Long destinationAccountId, Double amount)
throws AccountBlockedException {
if (amount <= 0) {
throw new IllegalArgumentException("Transfer amount must be positive.");
}
// 1. Fetch source account
BankAccount sourceAccount = accountRepository.findById(sourceAccountId)
.orElseThrow(() -> new IllegalArgumentException("Source account not found: " + sourceAccountId));
// 2. Fetch destination account
BankAccount destinationAccount = accountRepository.findById(destinationAccountId)
.orElseThrow(() -> new IllegalArgumentException("Destination account not found: " + destinationAccountId));
// 3. Verify source account status
if (Boolean.FALSE.equals(sourceAccount.getActive())) {
throw new AccountBlockedException("Source account is suspended.");
}
// 4. Validate balance (Throws RuntimeException -> Triggers automatic rollback)
if (sourceAccount.getBalance() < amount) {
throw new InsufficientFundsException("Insufficient funds to transfer $" + amount);
}
// 5. Apply debit
sourceAccount.setBalance(sourceAccount.getBalance() - amount);
accountRepository.save(sourceAccount);
// 6. Verify destination account status (Checked exception -> Rollback via rollbackFor)
if (Boolean.FALSE.equals(destinationAccount.getActive())) {
throw new AccountBlockedException("Destination account is suspended.");
}
// 7. Apply credit
destinationAccount.setBalance(destinationAccount.getBalance() + amount);
accountRepository.save(destinationAccount);
}
/**
* Read-only query method.
* readOnly = true optimizes performance by disabling Hibernate dirty checking.
*/
@Transactional(readOnly = true)
public Double checkBalance(Long accountId) {
return accountRepository.findById(accountId)
.map(BankAccount::getBalance)
.orElseThrow(() -> new IllegalArgumentException("Account not found"));
}
}
Verify Rollback Behavior with Unit Testing
Confirm with Mockito that destination save operations are skipped when balance checks fail.
package com.icesi.bank.service;
import com.icesi.bank.exception.InsufficientFundsException;
import com.icesi.bank.model.BankAccount;
import com.icesi.bank.repository.BankAccountRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class TransferServiceTest {
@Mock
private BankAccountRepository accountRepository;
@InjectMocks
private TransferService transferService;
@Test
@DisplayName("Should throw InsufficientFundsException and leave balance intact when funds are insufficient")
void shouldAbortTransactionWhenBalanceIsInsufficient() {
// Arrange
BankAccount source = BankAccount.builder().id(1L).balance(100.0).active(true).build();
BankAccount destination = BankAccount.builder().id(2L).balance(50.0).active(true).build();
when(accountRepository.findById(1L)).thenReturn(Optional.of(source));
when(accountRepository.findById(2L)).thenReturn(Optional.of(destination));
// Act & Assert
assertThrows(InsufficientFundsException.class, () -> {
transferService.transferMoney(1L, 2L, 500.0);
});
assertEquals(100.0, source.getBalance());
verify(accountRepository, never()).save(destination);
}
}
3. Common Pitfalls with @Transactional
If a method within a @Service bean calls another @Transactional method in the same class, transaction interception will NOT trigger:
public void processBatch() {
// ❌ Direct 'this' invocation bypasses the Spring AOP Proxy
this.executeTransaction();
}
@Transactional
public void executeTransaction() {
// Transactional logic...
}
Solution: Move the transactional method to a separate bean or self-inject the bean to route calls through the proxy.