00:00

Spring Boot Transaction Management

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 database operations treated as one unit of work. Either all of them are saved (a commit), or none of them are (a rollback).

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. The ACID Properties

Database transactions provide four guarantees, known as ACID:

PropertyMeaningIn the money transfer
AtomicityAll operations succeed, or none take effectThe money leaves A only if it arrives in B
ConsistencyData moves from one valid state to anotherThe total amount of money stays the same
IsolationConcurrent transactions do not see each other’s unfinished workNobody sees A debited but B not yet credited
DurabilityCommitted data survives crashesOnce confirmed, the transfer is not lost even if the server restarts

3. How Spring Manages Transactions

Spring manages transactions through a proxy: a wrapper object that Spring places around your bean. When another bean calls a @Transactional method, the call goes through the proxy first:

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

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. The Self-Invocation Trap

The most common transaction bug: a @Transactional method called from another method in the same class runs without its transaction settings.

StatementService.java (broken)
@Service
public class StatementService {

    public void importAll(List<Path> files) {
        for (Path file : files) {
            importOne(file);     // calls "this", not the proxy: no new transaction!
        }
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void importOne(Path file) { ... }
}

The call goes directly to the object, not through the proxy, so the proxy never gets a chance to start a transaction. The fix is to move the method into another bean:

StatementService.java (fixed)
@Service
public class StatementService {

    private final StatementImporter importer;   // a separate bean

    public StatementService(StatementImporter importer) {
        this.importer = importer;
    }

    public void importAll(List<Path> files) {
        for (Path file : files) {
            importer.importOne(file);   // goes through the proxy: a new transaction per file
        }
    }
}

@Service
public class StatementImporter {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void importOne(Path file) { ... }
}

Now each file is imported in its own transaction, so one bad file no longer undoes the others.

13. 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.

14. 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

15. 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.

16. 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.

17. Frequently Asked Questions (FAQ)

Q1. Do I need @EnableTransactionManagement in a Spring Boot application?

No. When you add a starter such as spring-boot-starter-data-jpa or spring-boot-starter-jdbc, Spring Boot’s auto-configuration switches transaction support on and creates the matching transaction manager. Adding the annotation does no harm, but it is not needed.

You only need it in plain Spring applications without Spring Boot, or in rare cases where you want to change how the transaction proxies are created.

Q2. What is the difference between Spring’s @Transactional and jakarta.transaction.Transactional?

Spring supports both, but they are different annotations:

org.springframework...Transactionaljakarta.transaction.Transactional
Comes fromSpringThe Jakarta EE standard
Isolation, timeout, read-onlyYesNo
Rollback settingsrollbackFor, noRollbackForrollbackOn, dontRollbackOn

In Spring applications, use Spring’s version: it offers more options. IDEs often suggest both when you type @Transactional, so check the import.

Q3. What does “Transaction silently rolled back because it has been marked as rollback-only” mean?

An inner @Transactional method that joined the outer transaction (the default REQUIRED) threw an exception. Spring marked the whole shared transaction as “rollback-only”. The outer method then caught the exception and tried to finish normally, but the transaction can no longer commit, so Spring throws UnexpectedRollbackException:

@Transactional
public void processBatch(List<Order> orders) {
    for (Order order : orders) {
        try {
            orderProcessor.process(order);   // @Transactional (REQUIRED) - joins this transaction
        } catch (InvalidOrderException ex) {
            log.warn("Skipping order {}", order.getId());   // swallowing does not help
        }
    }
}   // UnexpectedRollbackException here

If each item should succeed or fail on its own, give the inner method Propagation.REQUIRES_NEW, or validate before entering the transaction.

Q4. What is the difference between REQUIRES_NEW and NESTED?
REQUIRES_NEWNESTED
TransactionA completely separate transactionPart of the outer transaction, with a savepoint
Inner commitSaved immediately, even if the outer fails laterSaved only if the outer transaction commits
Inner failureOnly the inner work is undoneRolled back to the savepoint; the outer can continue
ConnectionsUses a second database connectionUses the same connection

Use REQUIRES_NEW for work that must survive no matter what, like audit logs. Use NESTED when part of a job may fail without spoiling the rest, but everything should still commit or roll back together at the end. NESTED relies on database savepoints, which JDBC-based transaction managers support.

Q5. Can one @Transactional method update two different databases?

Not atomically by default. With two data sources, you have two transaction managers, and a method uses only one of them, chosen by name:

@Transactional("ordersTransactionManager")
public void placeOrder(Order order) { ... }

@Transactional("billingTransactionManager")
public void createInvoice(Invoice invoice) { ... }

If both databases must change together, you need a distributed transaction (JTA/XA, with a separate transaction coordinator), which adds complexity and slows things down. Most teams instead design for it: one database per business operation, or a series of local steps with compensating actions if one fails.

Q6. Can a transaction span several microservices?

No. A database transaction lives inside one service and one database. When an order service calls a payment service over HTTP, the payment service commits its own transaction, and the order service cannot roll it back.

Microservices use the Saga pattern instead: each service commits a local transaction, and if a later step fails, earlier steps are undone by compensating actions, such as “refund the payment”. See the Saga Pattern tutorial.

Q7. Does a transaction lock the rows it reads?

Usually not, with the common default isolation levels:

  • Writes (update, delete) lock the affected rows until the transaction ends.
  • Plain reads do not lock rows under READ_COMMITTED in most databases; other transactions can still change them.

If you must stop others from changing a row you just read, either use optimistic locking with @Version, which detects conflicting changes, or explicitly lock it:

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT a FROM Account a WHERE a.number = :number")
Optional<Account> findForUpdate(@Param("number") String number);   // SELECT ... FOR UPDATE
Q8. How can I see in the logs when transactions start and end?

Turn on debug logging for the transaction classes:

logging.level.org.springframework.transaction=DEBUG
logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG
Creating new transaction with name [com.javabytechie.bank.TransferService.transfer]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Committing JPA transaction on EntityManager [...]

This shows exactly which methods start, join or roll back transactions, and quickly reveals problems such as a method that never gets a transaction because of self-invocation. Switch it off again afterwards, as it is very detailed.

Q9. Why are my entity changes saved even though I never call save()?

Inside a transaction, JPA keeps track of every entity it loaded. At commit time, it compares each entity with its original state and writes any changes automatically. This is called dirty checking:

@Transactional
public void changeEmail(Long customerId, String newEmail) {
    Customer customer = customerRepository.findById(customerId).orElseThrow();
    customer.setEmail(newEmail);   // UPDATE is executed automatically at commit
}

It also means accidental changes to a loaded entity are saved too. If a method should only read, mark it readOnly = true, and avoid changing entities you only meant to display.

Q10. Is it a problem to call another REST API inside a transaction?

Yes, for two reasons:

  • It holds resources. While waiting for the remote API, perhaps for seconds, your transaction keeps a database connection and any locks. A slow partner can then exhaust your connection pool.
  • It cannot be undone. If your transaction rolls back afterwards, the remote call has still happened.

Call remote services before the transaction, using the result inside it, or after the commit, for example with @TransactionalEventListener, so the transaction stays short and only contains database work.

Q11. Does readOnly = true stop my code from writing data?

Not reliably. It is mainly a performance hint. Hibernate skips change detection and flushing, so changes to loaded entities are usually just not saved, without any error.

Whether the database itself rejects writes depends on the database and driver. Some, such as PostgreSQL, raise an error for an INSERT in a read-only transaction; others may allow it. Never rely on readOnly as a security or safety mechanism; use it to express intent and gain performance.

Q12. Does @Transactional work with reactive code and WebFlux?

Yes. For methods returning Mono or Flux, Spring uses a reactive transaction manager, such as R2dbcTransactionManager with R2DBC:

@Transactional
public Mono<Void> transfer(String from, String to, BigDecimal amount) {
    return accounts.withdraw(from, amount)
            .then(accounts.deposit(to, amount));
}

The transaction is tied to the reactive pipeline rather than to a thread. Blocking JPA calls do not belong in reactive code, because they would block the few event-loop threads that WebFlux relies on.

Q13. What is a savepoint?

A savepoint is a marker inside a transaction. You can roll back to it, undoing only the work done after it, while keeping the work before it and continuing the transaction.

transactionTemplate.executeWithoutResult(status -> {
    saveHeader(statement);
    Object savepoint = status.createSavepoint();
    try {
        saveOptionalDetails(statement);
    } catch (DataAccessException ex) {
        status.rollbackToSavepoint(savepoint);   // undo only the details
    }
});

Propagation.NESTED uses savepoints for you automatically. Savepoints are supported by most relational databases through JDBC.

Q14. What is a deadlock, and how do I avoid it?

A deadlock happens when two transactions each wait for a lock that the other holds. For example, transaction 1 locks account A and wants B, while transaction 2 locks B and wants A. The database detects this and aborts one of them with an error.

To make deadlocks rare:

  • Lock rows in a consistent order, for example always the lower account number first.
  • Keep transactions short, so locks are held briefly.
  • Retry the whole transaction a few times when the database reports a deadlock.
// Always lock accounts in the same order, whatever the transfer direction
String first = from.compareTo(to) < 0 ? from : to;
String second = first.equals(from) ? to : from;
accountRepository.findForUpdate(first);
accountRepository.findForUpdate(second);
Q15. Should I put @Transactional on interfaces or on classes?

Put it on concrete classes or their methods. Current Spring versions also detect it on interfaces, but annotating the implementation is what Spring recommends: it works with every proxy type and makes the transactional behavior visible right where the code is.

Q16. If I catch an exception and throw a different one, which one decides the rollback?

The exception that leaves the method, because that is the one the proxy sees:

@Transactional
public void importFile(Path path) {
    try {
        Files.readAllLines(path).forEach(this::importLine);
    } catch (IOException ex) {
        throw new ImportFailedException(path, ex);   // unchecked -> rollback
    }
}

Wrapping a checked exception in an unchecked one, as here, is a common way to get a rollback without extra settings. Keep the original as the cause, so the real reason stays visible in the logs.

Q17. How do I send a message only if the database commit succeeds, without losing it?

@TransactionalEventListener(AFTER_COMMIT) sends it after the commit, but if the application crashes between the commit and the send, the message is lost.

When every message must be delivered, use the transactional outbox pattern:

  1. In the same transaction as your business change, insert the message into an outbox table.
  2. A separate process reads new rows from the outbox and publishes them to the broker, such as Kafka.
  3. It marks each row as sent once the broker confirms.

Because the business data and the outbox row commit together, a message can never be lost or sent for a rolled-back change.

Q18. Why does my application slow down when many requests use transactions?

Each transaction holds a database connection from the pool for its whole duration. Spring Boot’s default pool (HikariCP) has 10 connections. If transactions are slow, for example because they call remote services or process large files, requests start waiting for a free connection.

Fix the cause first: keep transactions short and move slow work outside them. Then size the pool for your load:

spring.datasource.hikari.maximum-pool-size=20
spring.datasource.hikari.connection-timeout=5000

A bigger pool is not always better, because the database can handle only so many parallel connections. Watch the pool metrics through Actuator before changing it.

Q19. Do methods that only read data need @Transactional at all?

Not strictly, since each query also runs on its own. But @Transactional(readOnly = true) brings real benefits:

  • Consistency: several queries in one method see the same snapshot of data, depending on the isolation level.
  • Lazy loading works inside the method, because the persistence context stays open.
  • Performance: Hibernate skips change detection.
  • One connection is used for all the queries, instead of one per query.

A common pattern is @Transactional(readOnly = true) on the service class, with plain @Transactional on the methods that write.

Q20. What is the difference between flush and commit?
FlushCommit
What it doesSends pending SQL statements to the databaseMakes the transaction’s changes permanent
Visible to others?No, still inside the transactionYes
Can still be undone?Yes, by a rollbackNo
HappensBefore queries that need it, before commit, or on flush()At the end of the @Transactional method

Calling flush() early is useful when you want database errors, such as a duplicate key, to appear at a specific point in your method instead of at the very end. It never replaces the commit.