Understanding Transactions in Spring Boot

When working with databases in Spring applications, it is essential to ensure operations are consistent and reliable. That is where transactions come in. This post explores what transactions are, how they work in Spring, and how to use the @Transactional annotation to manage transactions effectively.

Whether you’re using core Spring or Spring Boot, transaction management works the same way with @Transactional. The main difference in Spring Boot is that much of the configuration is handled automatically, allowing you to start using transactions right away.

What is a Transaction?

A transaction is a set of actions that need to be completed together. Think of it as a package of changes you want to make to your data all at once. For example, if you’re transferring money between bank accounts, both the withdrawal and deposit should either happen together or not at all.

When a transaction starts, any changes you make are temporary until everything is complete. If all steps go smoothly, Spring coordinates with the database to make the changes permanent. But if something goes wrong, Spring cancels the transaction, prompting the database to restore the data to its original state. This way, transactions help keep your data safe and consistent by either fully completing or undoing all changes.

How Transactions Work in Spring and Spring Boot

To keep things clear, the following explanation is a simplified version of what happens under the hood, as Spring’s internal transaction handling is slightly more complex. The code examples here are for illustrative purposes and may not represent the exact implementation used by Spring.

When you annotate a method with @Transactional, Spring automatically wraps the method in a proxy to handle transactions. Here’s how it works:

The Business Class: Original Bean
This is where the business logic resides. For example, a method that performs a bank transfer:

@Service  // Marks the class as a Spring bean to be managed by Spring
public class AccountService {

    @Transactional  // Annotation marks the method for transaction management
    public void transferMoney(Account source, Account destination, BigDecimal amount) {
        source.withdraw(amount);  // Debit from source account
        destination.deposit(amount);  // Credit to destination account
    }
}

The Proxy: Behind the Scenes

Spring creates a proxy around the AccountService class. The proxy handles the transaction lifecycle of starting, committing, or rolling back the transaction. Here’s how the proxy might look:

public class AccountServiceProxy extends AccountService {

    private TransactionManager transactionManager;

    public AccountServiceProxy(TransactionManager transactionManager) {
        this.transactionManager = transactionManager;
    }

    @Override
    public void transferMoney(Account source, Account destination, BigDecimal amount) {
        // Before method execution: Start the transaction
        transactionManager.beginTransaction();

        try {
            // Delegate to the original method
            super.transferMoney(source, destination, amount);  // Call actual method

            // After method execution: Commit the transaction if no exception occurs
            transactionManager.commit();

        } catch (RuntimeException ex) {
            // Rollback the transaction if an exception occurs
            transactionManager.rollback();
            throw ex;  // Propagate the exception
        } finally {
            // Cleanup resources (e.g., database connections)
            transactionManager.cleanup();
        }
    }
}

Proxy Delegation Flow
When you annotate a method with @Transactional, Spring automatically wraps your AccountService class in a proxy that handles the transaction management. Here’s how it works:

  1. Start the Transaction: Before the method is executed, the proxy calls beginTransaction() on the transaction manager to start a new transaction.
  2. Delegate to Business Logic: The proxy delegates the method call, such as transferMoney(), to the original AccountService method.
  3. Commit or Rollback: After the method completes, the proxy either commits the transaction if successful or rolls it back if an exception occurs. This ensures that the transaction is either fully completed or not executed at all.
  4. Cleanup: Regardless of success or failure, the proxy performs cleanup tasks such as releasing resources to ensure everything remains tidy.

Important Note: If you add a try-catch block inside the @Transactional transferMoney() method to handle a RuntimeException, Spring won’t be able to trigger a rollback because it won’t know the exception occurred. Let the exception propagate upwards. 

In summary, the proxy handles the entire transaction lifecycle of starting, committing, and rolling back, while the business logic is executed by the original class, AccountService.

Setting Up a Spring Boot Application with Transactions

Let’s implement a basic bank transfer using @Transactional with an H2 database setup. Consider a bank transfer between two accounts, which involves two steps:

  1. Withdrawing money from the source account.
  2. Depositing that money into the destination account.

If the withdrawal succeeds but the deposit fails, the money would be lost in transit. Wrapping these operations in a transaction ensures that if anything goes wrong, both actions will be canceled, keeping the data consistent and accurate.

Code Setup for Spring Boot

1. Dependencies (pom.xml)

Add the following dependencies to your pom.xml:

<dependencies>
    <!-- Spring Boot Starter for Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Spring Boot Starter for Data JPA -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>

    <!-- H2 Database (for testing purposes) -->
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>
</dependencies>

2. Application Class (Application.java)

This is the main entry point for your Spring Boot application.

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

3. Entity Class (Account.java)

The Account entity represents a bank account in our H2 database.

@Entity
public class Account {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String accountHolder;

    // BigDecimal is used for precision in financial calculations to avoid rounding errors common with floating-point numbers.
    private BigDecimal balance;

    // Constructors, getters, and setters
    public Account() {}

    public Account(String accountHolder, BigDecimal balance) {
        this.accountHolder = accountHolder;
        this.balance = balance;
    }

    public Long getId() {
        return id;
    }

    public String getAccountHolder() {
        return accountHolder;
    }

    public BigDecimal getBalance() {
        return balance;
    }

    public void setBalance(BigDecimal balance) {
        this.balance = balance;
    }
}

Spring Boot detects the presence of Spring Boot Starter Data JPA and H2 in the dependencies. It will then look for JPA annotations like @Entity, generate the corresponding SQL for H2, and run it to create the table for the Account entity.

4. Repository Interface (AccountRepository.java)

The repository interface allows interaction with the Account entity.

public interface AccountRepository extends JpaRepository<Account, Long> {}

5. Service Class (AccountService.java)

The AccountService class handles the money transfer between accounts using @Transactional.

@Service
public class AccountService {

    @Autowired
    private AccountRepository accountRepository;

    @Transactional
    public void transferMoney(Long sourceAccountId, Long destinationAccountId, BigDecimal amount) {
        Account sourceAccount = accountRepository.findById(sourceAccountId)
                .orElseThrow(() -> new IllegalArgumentException("Source account not found"));
        Account destinationAccount = accountRepository.findById(destinationAccountId)
                .orElseThrow(() -> new IllegalArgumentException("Destination account not found"));

        if (sourceAccount.getBalance().compareTo(amount) < 0) {
            throw new IllegalArgumentException("Insufficient balance in source account");
        }

        // Withdraw from the source account
        sourceAccount.setBalance(sourceAccount.getBalance().subtract(amount));
        accountRepository.save(sourceAccount);

        // Deposit to the destination account
        destinationAccount.setBalance(destinationAccount.getBalance().add(amount));
        accountRepository.save(destinationAccount);
    }
}

6. DTO Class for Transfer Requests (TransferRequest.java)

This class models the incoming JSON request for money transfer.

public class TransferRequest {
    private Long sourceAccountId;
    private Long destinationAccountId;
    private BigDecimal amount;

    // Getters and setters
}

7. Controller (TransactionController.java)

The controller receives HTTP requests and calls the service to process the transfer.

@RestController
@RequestMapping("/api")
public class TransactionController {

    @Autowired
    private AccountService accountService;

    @PostMapping("/transfer")
    public String transferMoney(@RequestBody TransferRequest request) {
        try {
            accountService.transferMoney(request.getSourceAccountId(), request.getDestinationAccountId(), request.getAmount());
            return "Transfer successful";
        } catch (RuntimeException e) {
            return "Transfer failed: " + e.getMessage();
        }
    }
}

Configuring H2 Database in application.yml

To use the H2 database for testing, configure it in application.yml:

spring:
  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
    username: sa
    password:
  h2:
    console:
      enabled: true

Testing the Transfer Endpoint

1. Start the Spring Boot application.

2. Open the H2 console at http://localhost:8080/h2-console. Use the JDBC URL jdbc:h2:mem:testdb and click ‘Connect’, as no password is configured in the application.yml

3. Copy the sample accounts below into the text area and click ‘Run’:

INSERT INTO ACCOUNT (account_holder, balance) VALUES ('Alice', 1000.00);
INSERT INTO ACCOUNT (account_holder, balance) VALUES ('Bob', 500.00);

4. Current Account table:

5. Send a POST request to transfer funds:

Expected outcome: Click ‘Send’. The transaction will complete successfully, and both accounts will have updated balances. Alice (user 1) will now have a balance of 800, and Bob (user 2) will now have a balance of 700.

6. Now, test with a failure scenario:

Expected outcome: This will simulate a failure because Alice doesn’t have enough funds in the account. Since the @Transactional annotation ensures that the transaction is only committed if all operations are successful, no changes will be made to either account. If Alice tries to transfer more money than she has (e.g., 2000.00 when her balance is 800.00), the transaction will be rolled back automatically, and no money will be transferred between accounts.

Conclusion

Using @Transactional in Spring Boot simplifies transaction management and helps ensure data consistency by rolling back changes when errors occur. In this example, we saw how to implement real transactions with H2, using @Transactional to handle a bank transfer between accounts, rolling back changes automatically if any error occurs.