In an enterprise Java application, business logic often requires modifying multiple tables or records in a single flow. If one part fails, the entire sequence must discard its changes (rollback) to prevent data corruption.
By combining Spring’s @Transactional annotation with our custom BusinessException hierarchy and Optional pipelines, you ensure that database integrity is protected whenever a rule is violated.
Production Implementation: Money Transfer Service
Here is a complete, real-world service layer showing how database state, transactional rollbacks, and Optional exceptions integrate cleanly.
@Service
public class AccountService {
@Autowired
private AccountRepository accountRepository;
@Autowired
private AuditLogRepository auditLogRepository;
/**
* Executes a secure funds transfer between two accounts.
* @Transactional ensures that if ANY exception occurs during execution,
* all database updates are instantly rolled back.
*/
@Transactional
public TransferResultDto transferFunds(String sourceId, String targetId, double amount) {
// 1. Fetch and validate source account using our Optional pipeline pattern
Account sourceAccount = accountRepository.findById(sourceId)
.orElseThrow(() -> new AccountNotFoundException(sourceId));
// 2. Fetch and validate target account
Account targetAccount = accountRepository.findById(targetId)
.orElseThrow(() -> new AccountNotFoundException(targetId));
// 3. Apply business constraint check using a custom runtime exception
if (sourceAccount.getBalance() < amount) {
throw new InsufficientFundsException(sourceId, amount, sourceAccount.getBalance());
}
// 4. Update the balances in-memory
sourceAccount.debit(amount);
targetAccount.credit(amount);
// 5. Persist the changes back to the database
accountRepository.save(sourceAccount);
accountRepository.save(targetAccount);
// 6. Write an audit record
AuditLog log = new AuditLog(sourceId, targetId, amount, "SUCCESS");
auditLogRepository.save(log);
return new TransferResultDto("TXN_SUCCESS", "Transfer completed smoothly.");
}
}Crucial Production Gotchas for Transaction Management
Why RuntimeException Matters Here
By default, Spring’s @Transactional only rolls back automatically when a RuntimeException (unchecked exception) is thrown.
- If your
AccountNotFoundExceptionorInsufficientFundsExceptionhad extended the checkedExceptionclass, Spring would catch it, continue executing, and commit the half-finished data mutations to your database. - Because our architecture uses
BusinessException extends RuntimeException, the rollback functions flawlessly out of the box.
Beware of the “Self-Invocation” Trap
Spring handles transactions using Proxies. When a controller calls accountService.transferFunds(), Spring intercepts the request, spins up a database transaction, executes the code, and commits it.
Proxy issue
However, if you call a
@Transactionalmethod from inside the same class, the Spring proxy is bypassed entirely, and the transaction configuration is completely ignored:
@Service
public class AccountService {
public void processBatch() {
// ❌ CRITICAL BUG: This call bypasses the Spring proxy.
// No transaction will be opened, and no rollback will happen if it fails!
this.transferFunds("ACC1", "ACC2", 100.00);
}
@Transactional
public void transferFunds(String src, String tgt, double amt) { ... }
}Best Practice
Always invoke
@Transactionalmethods from an external bean (like a Controller or a different Service class).
Keep Transaction Scopes Compact
Do not make network requests, call slow external REST APIs, or parse heavy files inside a @Transactional method.
- Database transactions hold onto open connection pool resources (
HikariCP). - If your method waits 5 seconds for a third-party API response while a transaction is open, your connection pool will starve under production load, freezing your entire microservice.
You can write Integration Tests using @SpringBootTest to verify that your transaction rollback successfully cancels database entries when an error is thrown,