Spring Boot Tutorial
Transactions in Spring Boot
A transaction groups several database operations so that they either all succeed or all fail. Transferring money, placing an order while reducing stock, or registering a user and their profile must never be left half-done. Spring's @Transactional makes this declarative — but it has rules about proxies, rollback and propagation that trip up even experienced developers.
This lesson explains how @Transactional works under the hood, which exceptions cause rollback, what propagation and isolation mean, why read-only transactions matter, the self-invocation trap, programmatic transactions with TransactionTemplate, and how to run code only after a transaction commits.
How @Transactional Works
Spring wraps beans that have @Transactional methods in a proxy. When another bean calls such a method, the proxy asks the PlatformTransactionManager (auto-configured as JpaTransactionManager with Spring Data JPA) to begin a transaction, calls your method, and commits — or rolls back if an exception escapes. Put @Transactional on service methods that represent one business operation, not on controllers or individual repository calls.
Rollback Rules
By default Spring rolls back for unchecked exceptions (RuntimeException and Error) and commits for checked exceptions. This surprises many developers. Use rollbackFor = Exception.class when checked exceptions should roll back, or noRollbackFor for exceptions that are expected and harmless. Catching an exception inside the method and not rethrowing it means no rollback at all.
Propagation
Propagation defines what happens when a transactional method calls another.
REQUIRED(default) — join the existing transaction, or start one if none exists.REQUIRES_NEW— suspend the current transaction and run in a brand new one that commits independently. Useful for audit logs that must survive a rollback.MANDATORY— must be called inside an existing transaction, otherwise throw.SUPPORTS,NOT_SUPPORTED,NEVER— run with, without, or forbid a transaction.NESTED— a savepoint within the current transaction (JDBC only, not supported by JpaTransactionManager for JPA entities).
Isolation and Read-Only
Isolation levels (READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE) control what concurrent transactions can see; the database default (READ_COMMITTED on PostgreSQL) is usually right, combined with optimistic locking for conflicting updates. readOnly = true tells Hibernate to skip dirty checking and lets the driver route to read replicas — use it for query-only service methods.
The Self-Invocation Trap
Because transactions are applied by a proxy, calling a @Transactional method from another method in the same class (this.doWork()) bypasses the proxy, and the annotation is ignored. Move the method into a separate bean, or use TransactionTemplate. The same applies to private methods: annotations on them are never applied.
After-Commit Actions
Sending an email or publishing a Kafka event from inside a transaction is risky: if the transaction later rolls back, the email is already sent. Publish an application event and handle it with @TransactionalEventListener(phase = AFTER_COMMIT) so side effects happen only after the data is safely committed.
Examples
A service method that must be all-or-nothing
@Service
public class CheckoutService {
private final OrderRepository orders;
private final ProductRepository products;
public CheckoutService(OrderRepository orders, ProductRepository products) {
this.orders = orders;
this.products = products;
}
@Transactional
public Order placeOrder(Customer customer, Map<Long, Integer> quantities) {
Order order = new Order(customer);
quantities.forEach((productId, qty) -> {
Product p = products.findById(productId).orElseThrow();
if (p.getStock() < qty) {
throw new OutOfStockException(p.getName()); // RuntimeException -> rollback
}
p.setStock(p.getStock() - qty); // dirty checking: UPDATE at commit, no save() needed
order.addItem(new OrderItem(p, qty));
});
return orders.save(order);
}
@Transactional(readOnly = true)
public List<Order> history(String email) {
return orders.findForCustomer(email, EnumSet.allOf(OrderStatus.class));
}
}
placeOrder(asha, {1:2, 2:1}) -> COMMIT: 1 order, 2 items, 2 stock updates
placeOrder(asha, {1:2, 3:99}) -> OutOfStockException("Hoodie")
ROLLBACK: stock of product 1 is NOT reduced, no order row created
Checked exceptions and rollbackFor
@Transactional
public void importCsv(Path file) throws IOException {
customers.save(new Customer("first"));
Files.readAllLines(file); // throws IOException (checked)
}
// -> IOException: the first customer IS committed (default: no rollback for checked exceptions)
@Transactional(rollbackFor = Exception.class)
public void importCsvSafely(Path file) throws IOException {
customers.save(new Customer("first"));
Files.readAllLines(file);
}
// -> IOException: everything rolled back
importCsv(missing.csv) -> NoSuchFileException, 1 row committed (surprise!)
importCsvSafely(missing.csv) -> NoSuchFileException, 0 rows committed
REQUIRES_NEW for an audit log that survives rollback
@Service
public class AuditService {
private final AuditRepository audit;
public AuditService(AuditRepository audit) {
this.audit = audit;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(String action, String user) {
audit.save(new AuditEntry(action, user, Instant.now()));
}
}
@Service
public class PaymentService {
private final AuditService auditService;
private final PaymentGateway gateway;
public PaymentService(AuditService auditService, PaymentGateway gateway) {
this.auditService = auditService;
this.gateway = gateway;
}
@Transactional
public void pay(Order order, String user) {
auditService.record("PAYMENT_ATTEMPT order=" + order.getId(), user); // separate bean -> proxy applies
gateway.charge(order); // throws PaymentDeclinedException
}
}
pay(...) fails with PaymentDeclinedException
-> payment transaction ROLLED BACK
-> audit_entry "PAYMENT_ATTEMPT order=41" COMMITTED (independent transaction)
Self-invocation bug, TransactionTemplate, and after-commit events
@Service
public class ReportService {
public void generateAll() {
for (Long id : ids()) {
generateOne(id); // BUG: self-call bypasses the proxy -> no transaction!
}
}
@Transactional
public void generateOne(Long id) { /* ... */ }
}
// Fix with TransactionTemplate: one transaction per item, explicit and visible
@Service
public class ReportServiceFixed {
private final TransactionTemplate tx;
public ReportServiceFixed(PlatformTransactionManager txManager) {
this.tx = new TransactionTemplate(txManager);
}
public void generateAll(List<Long> ids) {
ids.forEach(id -> tx.executeWithoutResult(status -> generateOne(id)));
}
private void generateOne(Long id) { /* ... */ }
}
// Send the confirmation email only after the order is committed
public record OrderPlacedEvent(Long orderId, String email) {}
@Transactional
public Order placeOrder(...) {
Order saved = orders.save(order);
events.publishEvent(new OrderPlacedEvent(saved.getId(), customer.getEmail()));
return saved;
}
@Component
class OrderEmailListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
void onPlaced(OrderPlacedEvent e) {
mailService.sendConfirmation(e.email(), e.orderId());
}
}
Order committed -> OrderEmailListener runs -> email sent
Order rolled back -> listener never runs -> no misleading email
Common Mistakes
- Annotating private methods or calling @Transactional methods from the same class; the proxy never sees the call.
- Assuming checked exceptions roll back the transaction — by default they commit.
- Catching an exception inside a transactional method and continuing, so the transaction commits partial work.
- Calling external services (HTTP, email) inside long transactions, holding database connections and locks for seconds.
- Putting @Transactional on controllers, which mixes web concerns with transaction boundaries.
Key Points to Remember
- @Transactional works through proxies: only external calls to public methods are intercepted.
- Default rollback happens for RuntimeException and Error; use rollbackFor for checked exceptions.
- REQUIRED joins an existing transaction; REQUIRES_NEW runs independently.
- Use readOnly = true for query-only methods.
- Trigger side effects after commit with @TransactionalEventListener.
Practice the examples
Change an input, predict the result, then compare it with the output. Explain why the result changes.
Use your local JDK or project IDE for these examples. Codelab currently runs Python and HTML/CSS/JavaScript; framework examples may need project dependencies.