00:00

Spring Boot Transaction Management

Last updated:

Some operations must happen completely or not at all. When you transfer money, taking it from one account and adding it to another must succeed together: if only the first half happens, money simply disappears. Transactions guarantee this, and Spring Boot makes them easy to use with a single annotation, @Transactional.

This tutorial explains how transactions work, how Spring applies them, and how to control rollback, propagation, isolation and timeouts, with a complete bank-transfer example, tests and the most common pitfalls.

1. What Is a Transaction?

A transaction is a group of one or more database operations treated as a single logical unit of work. Either all operations succeed and are committed, or the changes made by the transaction are rolled back if it fails, ensuring data consistency and integrity.

Example: Bank Account Transfer

A money transfer of 500 from account A to account B involves three steps:

StepOperation
1Subtract 500 from account A
2Add 500 to account B
3Record the transfer in the history table

If step 2 fails, for example because account B is closed, step 1 must be undone. Without a transaction, account A would have lost 500 that never arrived anywhere. With a transaction, the database rolls back step 1 automatically, as if nothing had happened.

2. ACID Properties of a Transaction

Database transactions provide four guarantees, known as ACID:

PropertyMeaning
AtomicityAll or nothing: every operation is applied, or none is. If a step fails, the completed steps are rolled back, so 500 leaves account A only if it also arrives in account B.
ConsistencyData moves from one valid state to another. The database rejects changes that break its constraints (keys, NOT NULL, CHECK), and business rules, such as the total amount of money staying the same, are never left half applied.
IsolationConcurrent transactions do not see each other’s uncommitted changes, so nobody reads account A debited before account B is credited. How strict this is depends on the isolation level.
DurabilityOnce committed, changes are saved permanently and survive a crash or server restart, so a confirmed transfer is never lost.

3. How Spring Boot Manages Transactions

Spring Boot manages transactions declaratively using AOP proxies and the PlatformTransactionManager interface.

When you annotate a method with @Transactional, Spring creates a proxy around that bean at startup. When the method is called, the proxy intercepts the request and delegates to the appropriate Transaction Manager (like JpaTransactionManager or DataSourceTransactionManager), which Spring Boot auto-configures based on your dependencies.

The manager then opens a transaction, binds it to the current thread, and executes your business logic. If the method completes successfully, it commits; if a runtime exception is thrown, it rolls back. Finally, the connection is released back to the pool.

Key points to remember:

  • Apply @Transactional mainly at the service layer.
  • Self-invocation (calling a transactional method from within the same class) bypasses the default proxy mechanism.
  • By default, unchecked exceptions (RuntimeException and Error) trigger rollback; checked exceptions do not unless rollback rules are configured.
What happens on each call
Caller ──▶ Proxy ──────────────────────────────▶ TransferService.transfer()
            │ 1. start a transaction                      │
            │                                             │ your code runs
            │ 3a. method returned normally  -> COMMIT  ◀──┤
            │ 3b. method threw an exception -> ROLLBACK ◀─┘

The actual work of beginning, committing and rolling back is done by a PlatformTransactionManager. Spring Boot creates the right one automatically:

You useTransaction manager created by Spring Boot
Spring Data JPA / HibernateJpaTransactionManager
Plain JDBC or JdbcClientDataSourceTransactionManager
Reactive R2DBCR2dbcTransactionManager

Transaction support is switched on automatically by these starters, so you do not need @EnableTransactionManagement in a Spring Boot application.

4. Using @Transactional

Here is a complete money transfer. The entity protects its own rules:

Account.java
@Entity
@Table(name = "accounts")
public class Account {

    @Id
    private String number;

    private BigDecimal balance;

    @Version
    private Long version;   // protects against concurrent updates

    protected Account() {
    }

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

    public void withdraw(BigDecimal amount) {
        if (balance.compareTo(amount) < 0) {
            throw new InsufficientFundsException(number, amount);
        }
        balance = balance.subtract(amount);
    }

    public void deposit(BigDecimal amount) {
        balance = balance.add(amount);
    }

    public BigDecimal getBalance() {
        return balance;
    }
}
InsufficientFundsException.java
public class InsufficientFundsException extends RuntimeException {

    public InsufficientFundsException(String account, BigDecimal amount) {
        super("Account " + account + " has insufficient funds for " + amount);
    }
}

The service method groups all steps into one transaction:

TransferService.java
package com.javabytechie.bank;

import java.math.BigDecimal;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class TransferService {

    private final AccountRepository accountRepository;
    private final TransferRepository transferRepository;

    public TransferService(AccountRepository accountRepository, TransferRepository transferRepository) {
        this.accountRepository = accountRepository;
        this.transferRepository = transferRepository;
    }

    @Transactional
    public void transfer(String fromNumber, String toNumber, BigDecimal amount) {
        Account from = accountRepository.findById(fromNumber)
                .orElseThrow(() -> new AccountNotFoundException(fromNumber));
        Account to = accountRepository.findById(toNumber)
                .orElseThrow(() -> new AccountNotFoundException(toNumber));

        from.withdraw(amount);     // may throw InsufficientFundsException
        to.deposit(amount);
        transferRepository.save(new Transfer(fromNumber, toNumber, amount));

        // no save() needed for the accounts: changes are written at commit
    }
}

If anything inside transfer() throws an unchecked exception, such as InsufficientFundsException or AccountNotFoundException, every change is rolled back. If it finishes normally, both balances and the history record are committed together.

5. Where to Put @Transactional

PlacementEffect
On a methodThat method runs in a transaction
On a classEvery public method of the class runs in a transaction
On bothThe method’s settings override the class’s settings
AccountQueryService.java
@Service
@Transactional(readOnly = true)          // default for every method: read-only
public class AccountQueryService {

    public BigDecimal balanceOf(String number) { ... }       // read-only

    @Transactional                        // overrides: a normal read-write transaction
    public void closeAccount(String number) { ... }
}

Put @Transactional on the service layer, on public methods of concrete classes. A service method represents one complete business operation, which is exactly what should succeed or fail as a whole.

6. Rollback Rules

Spring Boot determines whether to commit or roll back a transaction based on the type of exception thrown during method execution. By default, unchecked exceptions and errors trigger a rollback, while checked exceptions do not. You can customize these rules using the @Transactional annotation.

Exception thrownDefault behavior
Unchecked: RuntimeException and subclassesRollback
Error, such as OutOfMemoryErrorRollback
Checked: Exception subclasses such as IOExceptionCommit (no rollback)

The default for checked exceptions surprises many developers. Change it with the rollback attributes:

PaymentService.java
// Roll back on a specific checked exception
@Transactional(rollbackFor = PaymentGatewayException.class)
public void pay(Order order) throws PaymentGatewayException { ... }


// Roll back on every exception
@Transactional(rollbackFor = Exception.class)
public void importStatements(Path file) throws IOException { ... }


// Keep the changes even though this unchecked exception is thrown
@Transactional(noRollbackFor = NotificationFailedException.class)
public void approveLoan(Long loanId) { ... }

7. Transaction Propagation

Propagation decides what happens when a transactional method is called while a transaction is already running:

PropagationIf a transaction existsIf none existsTypical use
REQUIRED (default)Joins itStarts a new oneAlmost everything
REQUIRES_NEWPauses it and starts a separate oneStarts a new oneAudit logs that must be saved even if the caller fails
NESTEDStarts a nested part with a savepointStarts a new onePart of the work may fail without losing the rest
SUPPORTSJoins itRuns without a transactionRead methods that work either way
MANDATORYJoins itThrows an exceptionMethods that must never run alone
NOT_SUPPORTEDPauses it, runs without oneRuns without a transactionSlow non-database work
NEVERThrows an exceptionRuns without a transactionCode that must never run inside a transaction

7.1 REQUIRES_NEW in practice

Every transfer attempt must be recorded in an audit log, even failed ones. If the audit entry joined the transfer’s transaction, a failed transfer would roll it back too. REQUIRES_NEW gives it its own transaction:

AuditService.java
@Service
public class AuditService {

    private final AuditRepository auditRepository;

    public AuditService(AuditRepository auditRepository) {
        this.auditRepository = auditRepository;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void recordAttempt(String from, String to, BigDecimal amount) {
        auditRepository.save(new AuditEntry(from, to, amount, Instant.now()));
    }   // committed here, independently of the caller
}
TransferService.java
@Transactional
public void transfer(String fromNumber, String toNumber, BigDecimal amount) {
    auditService.recordAttempt(fromNumber, toNumber, amount);   // saved no matter what

    Account from = accountRepository.findById(fromNumber).orElseThrow();
    from.withdraw(amount);   // if this fails, the transfer rolls back but the audit entry stays
    // ...
}

Note that AuditService is a separate bean. Calling a REQUIRES_NEW method in the same class would skip the proxy, and no new transaction would start (see section 12).

8. Isolation Levels

When many transactions run at the same time, they can affect each other in three classic ways:

ProblemWhat happens
Dirty readTransaction 1 reads data that transaction 2 has changed but not committed yet, and transaction 2 then rolls back
Non-repeatable readTransaction 1 reads a row twice and gets different values, because transaction 2 committed a change in between
Phantom readTransaction 1 runs the same query twice and gets extra rows, because transaction 2 inserted new ones

Isolation levels decide which of these are prevented. Stricter levels are safer but allow less parallel work:

Isolation levelDirty readNon-repeatable readPhantom read
READ_UNCOMMITTEDPossiblePossiblePossible
READ_COMMITTEDPreventedPossiblePossible
REPEATABLE_READPreventedPreventedPossible
SERIALIZABLEPreventedPreventedPrevented
ReportService.java
@Transactional(isolation = Isolation.REPEATABLE_READ, readOnly = true)
public MonthlyStatement buildStatement(String accountNumber, YearMonth month) {
    // every read in this method sees a consistent view of the data
}

The default, Isolation.DEFAULT, uses the database’s own setting: READ_COMMITTED for PostgreSQL, Oracle and SQL Server, and REPEATABLE_READ for MySQL’s InnoDB engine. Most applications keep the default and protect specific updates with optimistic locking, such as the @Version field on Account.

9. Read-Only Transactions and Timeouts

9.1 Read-only transactions

AccountQueryService.java
@Transactional(readOnly = true)
public List<TransferSummary> recentTransfers(String accountNumber) {
    return transferRepository.findTop20ByFromNumberOrderByCreatedAtDesc(accountNumber)
            .stream()
            .map(TransferSummary::from)
            .toList();
}

readOnly = true tells Spring and Hibernate that nothing will be changed, so Hibernate skips change detection and the flush at the end, which saves time and memory. It also documents the method’s intent clearly.

9.2 Timeouts

A transaction that runs too long holds database locks and a connection. Set a time limit in seconds:

ReportService.java
@Transactional(timeout = 30)
public void recalculateInterest() {
    // rolled back with an exception if it runs longer than 30 seconds
}

Without a timeout, a transaction can wait as long as the database allows. A timeout turns a stuck operation into a clear error.

10. Programmatic Transactions

Sometimes you need finer control than an annotation offers, for example a transaction around only part of a method. Use TransactionTemplate:

StatementImportService.java
@Service
public class StatementImportService {

    private final TransactionTemplate transactionTemplate;
    private final StatementRepository statementRepository;

    public StatementImportService(PlatformTransactionManager transactionManager,
                                  StatementRepository statementRepository) {
        this.transactionTemplate = new TransactionTemplate(transactionManager);
        this.statementRepository = statementRepository;
    }

    public int importFile(Path file) throws IOException {
        List<StatementLine> lines = parse(file);        // slow file work: no transaction yet

        return transactionTemplate.execute(status -> {  // short transaction for the database work
            statementRepository.saveAll(lines);
            return lines.size();
        });
    }
}

To roll back without throwing an exception, call status.setRollbackOnly() inside the callback. For work that returns nothing, use transactionTemplate.executeWithoutResult(status -> ...).

@TransactionalTransactionTemplate
StyleDeclarative: an annotationProgrammatic: explicit code
ScopeThe whole methodExactly the block you choose
ReadabilityVery cleanMore code
Use whenAlmost alwaysOnly part of a method needs a transaction, or you need several in one method

11. Running Code After a Commit

Some actions should happen only after the data is safely committed, such as sending a confirmation email. If you send it inside the transaction and the commit then fails, the customer receives a confirmation for a transfer that never happened.

Publish an event inside the transaction, and handle it after the commit:

TransferService.java
@Transactional
public void transfer(String fromNumber, String toNumber, BigDecimal amount) {
    // ... withdraw, deposit, save the transfer
    events.publishEvent(new TransferCompletedEvent(fromNumber, toNumber, amount));
}
TransferNotifier.java
@Component
public class TransferNotifier {

    private final EmailService emailService;

    public TransferNotifier(EmailService emailService) {
        this.emailService = emailService;
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onTransferCompleted(TransferCompletedEvent event) {
        emailService.sendTransferConfirmation(event);   // runs only if the commit succeeded
    }
}
PhaseRuns when
AFTER_COMMIT (default)The transaction committed successfully
AFTER_ROLLBACKThe transaction was rolled back
AFTER_COMPLETIONAfter either a commit or a rollback
BEFORE_COMMITJust before the commit, still inside the transaction

12. Testing Transactions

Test that a failed transfer really leaves both balances unchanged. Do not put @Transactional on this test, so the service’s own transaction behaves exactly as in production:

TransferServiceTest.java
@SpringBootTest
class TransferServiceTest {

    @Autowired
    private TransferService transferService;

    @Autowired
    private AccountRepository accountRepository;

    @BeforeEach
    void setUp() {
        accountRepository.deleteAll();
        accountRepository.save(new Account("A-1", new BigDecimal("1000")));
        accountRepository.save(new Account("B-1", new BigDecimal("200")));
    }

    @Test
    void successfulTransferMovesMoney() {
        transferService.transfer("A-1", "B-1", new BigDecimal("300"));

        assertThat(balanceOf("A-1")).isEqualByComparingTo("700");
        assertThat(balanceOf("B-1")).isEqualByComparingTo("500");
    }

    @Test
    void failedTransferChangesNothing() {
        assertThatThrownBy(() -> transferService.transfer("A-1", "B-1", new BigDecimal("5000")))
                .isInstanceOf(InsufficientFundsException.class);

        assertThat(balanceOf("A-1")).isEqualByComparingTo("1000");   // rolled back
        assertThat(balanceOf("B-1")).isEqualByComparingTo("200");
    }

    private BigDecimal balanceOf(String number) {
        return accountRepository.findById(number).orElseThrow().getBalance();
    }
}

For repository tests, @DataJpaTest wraps each test in a transaction that is rolled back at the end, so tests never affect each other. Add @Commit to a test method if you really need its data saved.

13. Common Pitfalls

PitfallWhat goes wrongFix
Calling a @Transactional method from the same classIts transaction settings are ignoredMove it to another bean (section 12)
@Transactional on a private methodSilently ignoredUse public methods
Catching the exception inside the methodThe transaction commits partial workRethrow, or mark it rollback-only
Expecting rollback on a checked exceptionChanges are committedUse rollbackFor
Slow work, such as remote calls, inside a transactionDatabase connections and locks are held for a long timeDo slow work before or after the transaction
Sending emails or messages inside the transactionNotifications for work that is later rolled backUse @TransactionalEventListener
Creating the object with newNo proxy, so no transactionLet Spring inject the bean

14. Best Practices

  • Put transactions on service methods that represent one complete business operation.
  • Keep transactions short: load, change, save, and nothing slow in between.
  • Use readOnly = true for methods that only read data.
  • Let exceptions leave the method, so rollback happens automatically.
  • Decide how checked exceptions should behave, and use rollbackFor where needed.
  • Keep the default propagation unless you have a clear reason, and document the reason when you change it.
  • Protect concurrent updates with @Version instead of raising the isolation level everywhere.
  • Trigger side effects after commit with @TransactionalEventListener.
  • Test rollback behavior, not just the happy path.

15. Summary

Transactions turn several database operations into one reliable unit: all succeed, or none take effect. In Spring Boot, @Transactional on a public service method is usually all you need; Spring’s proxy starts the transaction, commits on success and rolls back on unchecked exceptions. Use rollbackFor for checked exceptions, REQUIRES_NEW for independent work like audit logs, isolation levels and @Version for concurrency, readOnly and timeouts for efficiency, and @TransactionalEventListener for actions after commit. Avoid self-invocation, keep transactions short, and always test that rollback really works.