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:
- Start the Transaction: Before the method is executed, the proxy calls
beginTransaction()
on the transaction manager to start a new transaction. - Delegate to Business Logic: The proxy delegates the method call, such as
transferMoney()
, to the originalAccountService
method. - 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.
- 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:
- Withdrawing money from the source account.
- 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.