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 AccountNotFoundException or InsufficientFundsException had extended the checked Exception class, 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 @Transactional method 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 @Transactional methods 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,