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:
| Step | Operation |
|---|---|
| 1 | Subtract 500 from account A |
| 2 | Add 500 to account B |
| 3 | Record 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:
| Property | Meaning | In the money transfer |
|---|---|---|
| Atomicity | All operations succeed, or none take effect | The money leaves A only if it arrives in B |
| Consistency | Data moves from one valid state to another | The total amount of money stays the same |
| Isolation | Concurrent transactions do not see each other’s unfinished work | Nobody sees A debited but B not yet credited |
| Durability | Committed data survives crashes | Once 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:
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 use | Transaction manager created by Spring Boot |
|---|---|
| Spring Data JPA / Hibernate | JpaTransactionManager |
Plain JDBC or JdbcClient | DataSourceTransactionManager |
| Reactive R2DBC | R2dbcTransactionManager |
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:
@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;
}
}
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:
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
| Placement | Effect |
|---|---|
| On a method | That method runs in a transaction |
| On a class | Every public method of the class runs in a transaction |
| On both | The method’s settings override the class’s settings |
@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 thrown | Default behavior |
|---|---|
Unchecked: RuntimeException and subclasses | Rollback |
Error, such as OutOfMemoryError | Rollback |
Checked: Exception subclasses such as IOException | Commit (no rollback) |
The default for checked exceptions surprises many developers. Change it with the rollback attributes:
// 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:
| Propagation | If a transaction exists | If none exists | Typical use |
|---|---|---|---|
REQUIRED (default) | Joins it | Starts a new one | Almost everything |
REQUIRES_NEW | Pauses it and starts a separate one | Starts a new one | Audit logs that must be saved even if the caller fails |
NESTED | Starts a nested part with a savepoint | Starts a new one | Part of the work may fail without losing the rest |
SUPPORTS | Joins it | Runs without a transaction | Read methods that work either way |
MANDATORY | Joins it | Throws an exception | Methods that must never run alone |
NOT_SUPPORTED | Pauses it, runs without one | Runs without a transaction | Slow non-database work |
NEVER | Throws an exception | Runs without a transaction | Code 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:
@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
}
@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:
| Problem | What happens |
|---|---|
| Dirty read | Transaction 1 reads data that transaction 2 has changed but not committed yet, and transaction 2 then rolls back |
| Non-repeatable read | Transaction 1 reads a row twice and gets different values, because transaction 2 committed a change in between |
| Phantom read | Transaction 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 level | Dirty read | Non-repeatable read | Phantom read |
|---|---|---|---|
READ_UNCOMMITTED | Possible | Possible | Possible |
READ_COMMITTED | Prevented | Possible | Possible |
REPEATABLE_READ | Prevented | Prevented | Possible |
SERIALIZABLE | Prevented | Prevented | Prevented |
@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
@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:
@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:
@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 -> ...).
@Transactional | TransactionTemplate | |
|---|---|---|
| Style | Declarative: an annotation | Programmatic: explicit code |
| Scope | The whole method | Exactly the block you choose |
| Readability | Very clean | More code |
| Use when | Almost always | Only 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:
@Transactional
public void transfer(String fromNumber, String toNumber, BigDecimal amount) {
// ... withdraw, deposit, save the transfer
events.publishEvent(new TransferCompletedEvent(fromNumber, toNumber, amount));
}
@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
}
}
| Phase | Runs when |
|---|---|
AFTER_COMMIT (default) | The transaction committed successfully |
AFTER_ROLLBACK | The transaction was rolled back |
AFTER_COMPLETION | After either a commit or a rollback |
BEFORE_COMMIT | Just 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.
@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:
@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:
@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
| Pitfall | What goes wrong | Fix |
|---|---|---|
Calling a @Transactional method from the same class | Its transaction settings are ignored | Move it to another bean (section 12) |
@Transactional on a private method | Silently ignored | Use public methods |
| Catching the exception inside the method | The transaction commits partial work | Rethrow, or mark it rollback-only |
| Expecting rollback on a checked exception | Changes are committed | Use rollbackFor |
| Slow work, such as remote calls, inside a transaction | Database connections and locks are held for a long time | Do slow work before or after the transaction |
| Sending emails or messages inside the transaction | Notifications for work that is later rolled back | Use @TransactionalEventListener |
Creating the object with new | No proxy, so no transaction | Let 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 = truefor methods that only read data. - Let exceptions leave the method, so rollback happens automatically.
- Decide how checked exceptions should behave, and use
rollbackForwhere needed. - Keep the default propagation unless you have a clear reason, and document the reason when you change it.
- Protect concurrent updates with
@Versioninstead 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...Transactional | jakarta.transaction.Transactional | |
|---|---|---|
| Comes from | Spring | The Jakarta EE standard |
| Isolation, timeout, read-only | Yes | No |
| Rollback settings | rollbackFor, noRollbackFor | rollbackOn, 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_NEW | NESTED | |
|---|---|---|
| Transaction | A completely separate transaction | Part of the outer transaction, with a savepoint |
| Inner commit | Saved immediately, even if the outer fails later | Saved only if the outer transaction commits |
| Inner failure | Only the inner work is undone | Rolled back to the savepoint; the outer can continue |
| Connections | Uses a second database connection | Uses 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_COMMITTEDin 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:
- In the same transaction as your business change, insert the message into an
outboxtable. - A separate process reads new rows from the outbox and publishes them to the broker, such as Kafka.
- 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?
| Flush | Commit | |
|---|---|---|
| What it does | Sends pending SQL statements to the database | Makes the transaction’s changes permanent |
| Visible to others? | No, still inside the transaction | Yes |
| Can still be undone? | Yes, by a rollback | No |
| Happens | Before 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.