Skip to main content

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

Transaction lifecycle in Spring Boot: Commit versus Rollback

ACID Properties

Database transactions adhere to four essential principles:

  1. Atomicity: The unit of work executes completely or not at all ("all or nothing").
  2. Consistency: The database transitions from one valid state to another, enforcing constraints, foreign keys, and rules.
  3. Isolation: Concurrent transactions execute without mutual interference or reading dirty, intermediate states.
  4. 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:

Sequence diagram of Spring Transactional Proxy and TransactionManager interception

Step-by-Step Proxy Flow

  1. Web Controller / Caller: Calls the service method unaware of the transactional proxy layer.
  2. Spring AOP Proxy: Intercepts the invocation before it reaches your target bean.
  3. PlatformTransactionManager: Opens a connection from the pool and disables database autocommit (SET autocommit = false).
  4. Target Service: Executes the core business logic (debits, credits, DML operations).
  5. 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 (RuntimeException or classes defined in rollbackFor), the proxy calls rollback(), undoing all pending changes.

Default Rollback Rules vs. rollbackFor

Critical Default Behavior

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

AttributePurposeDefault
rollbackForException classes that force a ROLLBACK.{RuntimeException.class, Error.class}
noRollbackForException classes that must not trigger a rollback.{}
readOnlyOptimizes Hibernate/JPA session performance by disabling entity dirty-checking.false
propagationDefines how the method behaves relative to existing transactions (e.g., REQUIRED, REQUIRES_NEW).Propagation.REQUIRED
isolationConfigures 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.

1

Define Domain Business Exceptions

Create explicit custom exceptions to model business failures.

src/main/java/com/icesi/bank/exception/InsufficientFundsException.java
package com.icesi.bank.exception;

// Unchecked exception (triggers rollback by default)
public class InsufficientFundsException extends RuntimeException {
public InsufficientFundsException(String message) {
super(message);
}
}
src/main/java/com/icesi/bank/exception/AccountBlockedException.java
package com.icesi.bank.exception;

// Checked exception (requires explicit rollbackFor configuration)
public class AccountBlockedException extends Exception {
public AccountBlockedException(String message) {
super(message);
}
}
2

Model the BankAccount Entity

Define the entity with balance and active status fields.

src/main/java/com/icesi/bank/model/BankAccount.java
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;
}
3

Define the Spring Data Repository

Declare queries for finding accounts by account number.

src/main/java/com/icesi/bank/repository/BankAccountRepository.java
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);
}
4

Implement the Transactional Service with Rollback

Annotate with @Transactional(rollbackFor = {AccountBlockedException.class, Exception.class}) to guarantee atomicity.

src/main/java/com/icesi/bank/service/TransferService.java
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"));
}
}
5

Verify Rollback Behavior with Unit Testing

Confirm with Mockito that destination save operations are skipped when balance checks fail.

src/test/java/com/icesi/bank/service/TransferServiceTest.java
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

Beware of Self-Invocation

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.


4. Self-Assessment Quiz

Cargando cuestionario...