Course topics

By WebNest Studio

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

Java
@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));
    }
}
Output
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

Java
@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
Output
importCsv(missing.csv)        -> NoSuchFileException, 1 row committed (surprise!)
importCsvSafely(missing.csv)  -> NoSuchFileException, 0 rows committed

REQUIRES_NEW for an audit log that survives rollback

Java
@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
    }
}
Output
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

Java
@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());
    }
}
Output
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.