A transaction groups several database operations into one unit of work, so they either all succeed or none of them take effect. If every step succeeds, the transaction is committed and the changes become permanent; if any step fails, it is rolled back and the database returns to its earlier state. In Spring Boot you mark a service method with @Transactional, and Spring starts, commits, or rolls back the transaction around it. It is sed wherever data must stay consistent, such as bank transfers, order placement, and fee payments.
Features
- All-or-nothing behavior (atomicity): a bank transfer never leaves money debited but not credited
- Automatic commit when the method finishes normally
- Automatic rollback when a
RuntimeExceptionorErroris thrown rollbackForto roll back on checked exceptions too- Manual rollback with
setRollbackOnly()for business rules readOnly = truefor query methods- Works through Spring’s proxy, so the annotation is applied on the service method called from outside
- Transactions can be traced in the console with
logging.level.org.springframework.transaction.interceptor=TRACE
Hands-on Experiment: Creating a spring boot application by ensuring Data Integrity through Atomic Commits and Rollbacks
The following steps are involved in developing this hands-on experiment.
Step 1: Generate the Project
Step 2: Project Structure
Step 3: pom.xml
Step 4: application.properties
Step 5: Create Entity Class
Step 6: Create Repository Interface
Step 7: Create Service Class
Step 8: Create Controller Class
Step 9: Main Application Class
Step 10: Run the Main Application Class
Step 1: Configure the Project on Spring Initializr
Go to start.spring.io and set:
- Project: Maven
- Language: Java
- Spring Boot version: 3.2.x
- Group: com.example
- Artifact: bank
- Name: bank
- Package name: com.example.bank
- Packaging: Jar
- Java: 17
- Dependencies: Spring Web, Spring Data JPA, MySQL Driver.
Click Generate to download the ZIP, then extract and import it into your IDE as a Maven project.
Step 2: Generated Project Structure

Step 3: Explore pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>bank-transfer-demo</artifactId>
<version>1.0.0</version>
<name>bank-transfer-demo</name>
<description>Transaction Basics, Commit and Rollback - bank transfer</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<!-- spring-boot-starter-data-jpa already brings Spring transaction support (@Transactional) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
Step 4: application.properties
spring.application.name=bank-transfer-demo
# ---------- MySQL ----------
# Create the database first: CREATE DATABASE bank_transfer_db;
# (the tables must use InnoDB - this is the MySQL default - because MyISAM has no transactions)
spring.datasource.url=jdbc:mysql://localhost:3306/bank_transfer_db
spring.datasource.username=root
spring.datasource.password=password
spring.jpa.hibernate.ddl-auto=update
server.port=8082
# Keep the persistence context limited to the transaction, so what you see is exactly what the database holds
spring.jpa.open-in-view=false
# Show every SQL statement - watch the UPDATEs happen and then get rolled back
spring.jpa.show-sql=true
# ---------- Watch transactions in the console ----------
# TRACE prints: "Getting transaction for ..." / "Completing transaction ..." / "Completing transaction after exception ..."
logging.level.root=INFO
logging.level.org.springframework.transaction.interceptor=TRACE
logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG
logging.level.com.example.bank=DEBUG
logging.pattern.console=%d{HH:mm:ss.SSS} | %-5level | %msg%n
# ---------- Business rule ----------
# Used by /transfers/with-limit : a transfer above this amount is rolled back
app.bank.transfer-limit=3000Code language: PHP (php)
Step 5: Create Entity Class
package com.example.bank.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "accounts")
public class Account {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String accountNumber;
@Column(nullable = false)
private String holderName;
// Always use BigDecimal for money, never double
@Column(nullable = false, precision = 19, scale = 2)
private BigDecimal balance;
public Account() {
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getAccountNumber() { return accountNumber; }
public void setAccountNumber(String accountNumber) { this.accountNumber = accountNumber; }
public String getHolderName() { return holderName; }
public void setHolderName(String holderName) { this.holderName = holderName; }
public BigDecimal getBalance() { return balance; }
public void setBalance(BigDecimal balance) { this.balance = balance; }
}
package com.example.bank.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
// History row of a completed transfer. It is saved in the SAME transaction as the debit and credit,
// so when a transfer is rolled back, this row disappears too.
@Entity
@Table(name = "transfer_records")
public class TransferRecord {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String fromAccount;
private String toAccount;
@Column(precision = 19, scale = 2)
private BigDecimal amount;
private LocalDateTime createdAt;
public TransferRecord() {
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getFromAccount() { return fromAccount; }
public void setFromAccount(String fromAccount) { this.fromAccount = fromAccount; }
public String getToAccount() { return toAccount; }
public void setToAccount(String toAccount) { this.toAccount = toAccount; }
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}
Step 6: Create below Repository Interface
package com.example.bank.repository;
import com.example.bank.entity.Account;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface AccountRepository extends JpaRepository<Account, Long> {
Optional<Account> findByAccountNumber(String accountNumber);
boolean existsByAccountNumber(String accountNumber);
}
package com.example.bank.repository;
import com.example.bank.entity.TransferRecord;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface TransferRecordRepository extends JpaRepository<TransferRecord, Long> {
List<TransferRecord> findTop20ByOrderByIdDesc();
}
Step 7: Create below Service class
package com.example.bank.service;
import com.example.bank.entity.Account;
import com.example.bank.repository.AccountRepository;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.List;
@Service
public class AccountService {
private final AccountRepository accountRepository;
public AccountService(AccountRepository accountRepository) {
this.accountRepository = accountRepository;
}
public Account create(Account account) {
if (accountRepository.existsByAccountNumber(account.getAccountNumber())) {
throw new IllegalArgumentException("Account already exists: " + account.getAccountNumber());
}
if (account.getBalance() == null || account.getBalance().compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("Opening balance must be zero or more");
}
return accountRepository.save(account);
}
public List<Account> getAll() {
return accountRepository.findAll();
}
public Account getByNumber(String accountNumber) {
return accountRepository.findByAccountNumber(accountNumber)
.orElseThrow(() -> new IllegalArgumentException("Account not found: " + accountNumber));
}
}
package com.example.bank.service;
import com.example.bank.entity.Account;
import com.example.bank.entity.TransferRecord;
import com.example.bank.repository.AccountRepository;
import com.example.bank.repository.TransferRecordRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.interceptor.TransactionAspectSupport;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Service
public class TransferService {
private static final Logger log = LoggerFactory.getLogger(TransferService.class);
// A CHECKED exception (extends Exception). Spring does NOT roll back on checked exceptions by default.
public static class TransferFailedException extends Exception {
public TransferFailedException(String message) {
super(message);
}
}
private final AccountRepository accountRepository;
private final TransferRecordRepository transferRecordRepository;
@Value("${app.bank.transfer-limit}")
private BigDecimal transferLimit;
public TransferService(AccountRepository accountRepository, TransferRecordRepository transferRecordRepository) {
this.accountRepository = accountRepository;
this.transferRecordRepository = transferRecordRepository;
}
// ==================================================================
// 1. THE CORRECT WAY: debit + credit + history = ONE transaction
// rollbackFor = Exception.class -> roll back on runtime AND checked exceptions
// ==================================================================
@Transactional(rollbackFor = Exception.class)
public TransferRecord transfer(String from, String to, BigDecimal amount, String failureMode)
throws TransferFailedException {
return doTransfer(from, to, amount, failureMode);
}
// ==================================================================
// 2. NO TRANSACTION: every save() commits by itself.
// A failure after the debit leaves the money DEBITED but never CREDITED (money lost).
// ==================================================================
public TransferRecord transferWithoutTransaction(String from, String to, BigDecimal amount, String failureMode)
throws TransferFailedException {
return doTransfer(from, to, amount, failureMode);
}
// ==================================================================
// 3. DEFAULT ROLLBACK RULES: plain @Transactional.
// Rolls back on RuntimeException and Error, but COMMITS when a checked exception is thrown.
// ==================================================================
@Transactional
public TransferRecord transferDefaultRules(String from, String to, BigDecimal amount, String failureMode)
throws TransferFailedException {
return doTransfer(from, to, amount, failureMode);
}
// ==================================================================
// 4. MANUAL ROLLBACK: no exception, but the business rule says "undo it".
// setRollbackOnly() marks the transaction so it is rolled back instead of committed.
// ==================================================================
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> transferWithLimit(String from, String to, BigDecimal amount, String failureMode)
throws TransferFailedException {
TransferRecord record = doTransfer(from, to, amount, failureMode);
Map<String, Object> result = new LinkedHashMap<>();
if (amount.compareTo(transferLimit) > 0) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
log.warn("Amount {} is above the limit {} -> transaction marked for ROLLBACK", amount, transferLimit);
result.put("committed", false);
result.put("reason", "Amount " + amount + " is above the limit " + transferLimit
+ ". Debit, credit and history were rolled back.");
} else {
result.put("committed", true);
result.put("transfer", record);
}
return result;
}
// ------------------------------------------------------------------
// The actual work: validate -> DEBIT -> (simulated failure) -> CREDIT -> history
// It has no @Transactional itself; it joins whatever transaction the caller started (or none).
// ------------------------------------------------------------------
private TransferRecord doTransfer(String from, String to, BigDecimal amount, String failureMode)
throws TransferFailedException {
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Amount must be greater than zero");
}
if (from.equals(to)) {
throw new IllegalArgumentException("Source and target account must be different");
}
Account source = accountRepository.findByAccountNumber(from)
.orElseThrow(() -> new IllegalArgumentException("Account not found: " + from));
Account target = accountRepository.findByAccountNumber(to)
.orElseThrow(() -> new IllegalArgumentException("Account not found: " + to));
if (source.getBalance().compareTo(amount) < 0) {
// RuntimeException -> rolls back in every transactional method
throw new IllegalStateException("Insufficient funds in " + from + ": balance "
+ source.getBalance() + ", requested " + amount);
}
// ----- STEP 1: DEBIT -----
source.setBalance(source.getBalance().subtract(amount));
accountRepository.saveAndFlush(source); // the UPDATE is sent to MySQL right now
log.info("STEP 1 DEBIT : {} debited by {} (new balance {})", from, amount, source.getBalance());
// ----- simulated crash between debit and credit -----
if ("AFTER_DEBIT_RUNTIME".equals(failureMode)) {
log.error("Simulated RUNTIME failure after debit");
throw new IllegalStateException("Simulated crash after debit (runtime exception)");
}
if ("AFTER_DEBIT_CHECKED".equals(failureMode)) {
log.error("Simulated CHECKED failure after debit");
throw new TransferFailedException("Simulated bank-network failure after debit (checked exception)");
}
// ----- STEP 2: CREDIT -----
target.setBalance(target.getBalance().add(amount));
accountRepository.saveAndFlush(target);
log.info("STEP 2 CREDIT: {} credited by {} (new balance {})", to, amount, target.getBalance());
// ----- STEP 3: HISTORY -----
TransferRecord record = new TransferRecord();
record.setFromAccount(from);
record.setToAccount(to);
record.setAmount(amount);
record.setCreatedAt(LocalDateTime.now());
TransferRecord saved = transferRecordRepository.save(record);
log.info("STEP 3 RECORD: transfer history saved with id={}", saved.getId());
return saved;
}
// ------------------------------------------------------------------
// Read helpers
// ------------------------------------------------------------------
// Balances straight from the database + the total. The total must stay constant after every correct transfer.
@Transactional(readOnly = true)
public Map<String, Object> balances() {
Map<String, Object> result = new LinkedHashMap<>();
BigDecimal total = BigDecimal.ZERO;
for (Account a : accountRepository.findAll()) {
result.put(a.getAccountNumber(), a.getBalance());
total = total.add(a.getBalance());
}
result.put("TOTAL", total);
return result;
}
public List<TransferRecord> history() {
return transferRecordRepository.findTop20ByOrderByIdDesc();
}
}
Step 8: Create Controller class
package com.example.bank.controller;
import com.example.bank.entity.Account;
import com.example.bank.service.AccountService;
import com.example.bank.service.TransferService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/accounts")
public class AccountController {
private final AccountService accountService;
private final TransferService transferService;
public AccountController(AccountService accountService, TransferService transferService) {
this.accountService = accountService;
this.transferService = transferService;
}
@PostMapping
public ResponseEntity<?> create(@RequestBody Account account) {
try {
return ResponseEntity.status(HttpStatus.CREATED).body(accountService.create(account));
} catch (IllegalArgumentException ex) {
return ResponseEntity.badRequest().body(Map.of("error", ex.getMessage()));
}
}
@GetMapping
public List<Account> getAll() {
return accountService.getAll();
}
// All balances and their TOTAL - the quickest way to check if money was lost
@GetMapping("/balances")
public Map<String, Object> balances() {
return transferService.balances();
}
@GetMapping("/{accountNumber}")
public ResponseEntity<?> getByNumber(@PathVariable String accountNumber) {
try {
return ResponseEntity.ok(accountService.getByNumber(accountNumber));
} catch (IllegalArgumentException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", ex.getMessage()));
}
}
}
package com.example.bank.controller;
import com.example.bank.entity.TransferRecord;
import com.example.bank.service.TransferService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/transfers")
public class TransferController {
private final TransferService transferService;
public TransferController(TransferService transferService) {
this.transferService = transferService;
}
// Request body for all transfer URLs:
// { "fromAccount": "ACC1001", "toAccount": "ACC1002", "amount": 2000, "failureMode": "NONE" }
// failureMode: NONE | AFTER_DEBIT_RUNTIME | AFTER_DEBIT_CHECKED
// 1. Correct transaction (rolls back on any exception)
@PostMapping
public ResponseEntity<?> transfer(@RequestBody Map<String, Object> request) {
return perform(request, transferService::transfer);
}
// 2. No transaction at all
@PostMapping("/without-transaction")
public ResponseEntity<?> withoutTransaction(@RequestBody Map<String, Object> request) {
return perform(request, transferService::transferWithoutTransaction);
}
// 3. Plain @Transactional - commits on a checked exception
@PostMapping("/default-rollback-rules")
public ResponseEntity<?> defaultRules(@RequestBody Map<String, Object> request) {
return perform(request, transferService::transferDefaultRules);
}
// 4. Manual rollback with setRollbackOnly() when the amount is above the limit
@PostMapping("/with-limit")
public ResponseEntity<?> withLimit(@RequestBody Map<String, Object> request) {
return perform(request, transferService::transferWithLimit);
}
@GetMapping
public List<TransferRecord> history() {
return transferService.history();
}
// ------------------------------------------------------------------
@FunctionalInterface
private interface TransferCall {
Object run(String from, String to, BigDecimal amount, String failureMode) throws Exception;
}
// Runs one transfer and always returns the balances afterwards, so the effect of commit / rollback is visible
private ResponseEntity<?> perform(Map<String, Object> request, TransferCall call) {
Map<String, Object> response = new LinkedHashMap<>();
HttpStatus status;
try {
String from = String.valueOf(request.get("fromAccount"));
String to = String.valueOf(request.get("toAccount"));
BigDecimal amount = new BigDecimal(String.valueOf(request.get("amount")));
String mode = request.get("failureMode") == null ? "NONE" : String.valueOf(request.get("failureMode"));
response.put("result", call.run(from, to, amount, mode));
status = HttpStatus.OK;
} catch (IllegalArgumentException ex) { // includes NumberFormatException
response.put("error", ex.getMessage());
status = HttpStatus.BAD_REQUEST;
} catch (IllegalStateException ex) {
response.put("error", ex.getMessage());
status = HttpStatus.CONFLICT;
} catch (Exception ex) {
response.put("error", ex.getMessage());
status = HttpStatus.INTERNAL_SERVER_ERROR;
}
response.put("balancesNow", transferService.balances());
return ResponseEntity.status(status).body(response);
}
}
Step 9: Main Application
package com.example.bank;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
// Spring Boot enables transaction management automatically - no @EnableTransactionManagement needed
@SpringBootApplication
public class BankApplication {
public static void main(String[] args) {
SpringApplication.run(BankApplication.class, args);
}
}
Step 10: Run the StudentRestCurdApplication



