Foundation · Build

Transactions (@Transactional)

@Transactional wraps a method in one atomic unit; rollback defaults cover only unchecked exceptions—a default that quietly commits checked-exception flows.

Primary source: Framework 6.2 transaction reference

20 min

Lesson 13 of 17

1d streak

The Architectural Question

Partial commits from checked exceptions leave paid orders without inventory decrements—the kind of integrity bug that only appears under real failures.

What this guide proves

  • @Transactional
  • Rollback Rules
  • Propagation

Atomic business boundaries

Applications frequently execute multiple related database writes. For example, when an order is placed, the system must both deduct inventory stock and record the order. If the system crashes after deducting stock but before recording the order, the inventory is permanently lost. An atomic boundary guarantees that either all related writes succeed together, or none of them persist.

In Spring Boot, developers apply @Transactional to public application service methods to declare these atomic boundaries. The service method defines the complete use case, ensuring that persistence logic does not leak into HTTP controllers or background job runners.

How @Transactional intercepts method calls

The proxy boundary is covered in Core Spring; this lesson follows what that boundary does specifically for JPA. When an external caller enters an advised service method, Spring binds a persistence transaction to the current execution so repository operations share one database unit of work.

  1. 01Caller invokes OrderService.placeOrder().
  2. 02The Spring AOP Proxy intercepts the call and delegates to a TransactionInterceptor.
  3. 03The JpaTransactionManager obtains a physical database connection from the connection pool (e.g., HikariCP) and begins a database transaction.
  4. 04The actual target method executes its business logic and repository queries.
  5. 05If the method completes normally, the transaction commits and the connection returns to the pool.
  6. 06If an unhandled exception propagates out of the method, the interceptor may roll back the transaction.

Logical scopes vs physical resources

A logical transaction boundary (the @Transactional annotation) does not always map 1:1 to a new physical database connection. If a transactional method calls another transactional method, the second method typically joins the existing physical transaction (Propagation.REQUIRED) rather than acquiring a second connection.

Rollback rules and exceptions

By default, Spring rolls back transactions ONLY on unchecked exceptions (RuntimeException and Error). If a method throws a checked exception (e.g., IOException or a custom InsufficientStockException extending Exception), Spring will commit the transaction by default.

Use it when

When throwing a checked exception that indicates a business failure where partial writes should be discarded, explicitly configure the boundary using @Transactional(rollbackFor = InsufficientStockException.class).

Reconsider when

Avoid blanket advice like always using rollbackFor = Exception.class. Instead, choose an intentional atomic boundary and rollback policy that fits the business logic. If a checked exception represents a recoverable alternate flow (like an external API timeout where you want to save a "pending" status), you might actually want the transaction to commit.

Active Recall Checkpoint

If a service method throws a custom InvalidConfigurationException (which extends RuntimeException), will the transaction commit or roll back by default?

Hint: Consider the inheritance hierarchy of RuntimeException.

Show answer & explanation

It will roll back. Spring rolls back on all unchecked exceptions by default, and RuntimeException is unchecked.

The self-invocation trap

Because Spring transactions rely on AOP proxies, the transaction interceptor only runs when a method is called from *outside* the bean. If one method in a service calls another @Transactional method in the *same* service, it bypasses the proxy.

Outer transactions still govern work

Self-invocation applies no *new* advice from the called method. However, do not equate self-invocation with always having no transaction. If the outer method is already transactional, the inner method's database operations will successfully execute within that existing outer transaction.

Verification and debugging

Testing transactions requires proving database outcomes from clean checkpoints. A common testing trap is relying on @Transactional test methods, which automatically roll back at the end of the test. A surrounding rollback-only test transaction can conceal the actual commit/rollback behavior you are trying to verify.

To prove rollback using a meaningful negative control and database state, verify the committed state from *another transaction or connection* after the service call completes. Use Spring's TransactionTemplate or explicitly configure the test not to roll back, ensuring you observe the true persistent state.

Runtime Architecture & Flow

High-value visual model

@Transactional Runtime Boundary

A transaction is created by proxy advice around a public method call. The persistence context tracks changes until flush and commit.

1. External call enters proxy

Spring transaction advice runs before your service method.

2. Transaction and persistence context open

JPA entity changes are tracked inside the active boundary.

3. Use case changes state

Domain code modifies managed entities and calls repositories.

4. Flush, then commit or rollback

SQL is synchronized before commit; runtime exceptions normally roll back.

Transaction boundary sequence: Place @Transactional around a complete use case, not around random repository calls.

Working Implementation

Code Walkthrough

Create a service method that saves an order, then intentionally throws a RuntimeException. Verify the order is rolled back.

OrderTransactionTest.java
package com.example.transactions;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.context.annotation.Import;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

/**
 * Maven Checkpoint Dependencies (pom.xml):
 * - org.springframework.boot:spring-boot-starter-data-jpa:3.5.16
 * - com.h2database:h2:runtime
 * - org.springframework.boot:spring-boot-starter-test:3.5.16 (test scope)
 *
 * Execution command: mvn test -Dtest=OrderTransactionTest
 */
@DataJpaTest
// Disable surrounding test transaction so commits and rollbacks persist to the test DB
@Transactional(propagation = Propagation.NOT_SUPPORTED)
@EnableJpaRepositories(considerNestedRepositories = true)
@EntityScan(basePackageClasses = OrderTransactionTest.class)
@Import(OrderTransactionTest.OrderService.class)
public class OrderTransactionTest {

  @SpringBootApplication
  static class TestApplication {}

  @Autowired OrderRepository orders;
  @Autowired InventoryRepository inventory;
  @Autowired OrderService orderService;

  @BeforeEach
  void resetState() {
    orders.deleteAll();
    inventory.deleteAll();
  }

  @Entity
  @Table(name = "lab_orders")
  public static class Order {
    @Id private String id;
    private String sku;
    public Order() {}
    public Order(String id, String sku) { this.id = id; this.sku = sku; }
    public String getId() { return id; }
    public String getSku() { return sku; }
  }

  @Entity
  @Table(name = "lab_inventory")
  public static class Inventory {
    @Id private String sku;
    private int stock;
    public Inventory() {}
    public Inventory(String sku, int stock) { this.sku = sku; this.stock = stock; }
    public String getSku() { return sku; }
    public int getStock() { return stock; }
    public void setStock(int stock) { this.stock = stock; }
  }

  public interface OrderRepository extends JpaRepository<Order, String> {}
  public interface InventoryRepository extends JpaRepository<Inventory, String> {}

  @Service
  public static class OrderService {
    @Autowired OrderRepository orders;
    @Autowired InventoryRepository inventory;

    @Transactional
    public void placeOrderUnchecked(String orderId, String sku) {
      inventory.save(new Inventory(sku, 9)); // Deduct stock
      orders.save(new Order(orderId, sku));
      throw new IllegalStateException("Simulated unchecked crash!");
    }

    @Transactional // Commits by default on checked exceptions!
    public void placeOrderChecked(String orderId, String sku) throws Exception {
      inventory.save(new Inventory(sku, 9));
      orders.save(new Order(orderId, sku));
      throw new Exception("Checked business rule failure");
    }

    @Transactional(rollbackFor = Exception.class) // Repaired: rolls back on checked exceptions
    public void placeOrderRepaired(String orderId, String sku) throws Exception {
      inventory.save(new Inventory(sku, 9));
      orders.save(new Order(orderId, sku));
      throw new Exception("Checked business rule failure - repaired");
    }

    public void selfInvocationBypass(String orderId, String sku) {
      // Direct call bypasses proxy; service-level transaction is NOT started.
      // Notice: underlying repository calls commit individually, resulting in partial write!
      this.placeOrderUnchecked(orderId, sku);
    }
  }

  @Test
  void uncheckedExceptionsRollBackEntireServiceTransaction() {
    inventory.save(new Inventory("SKU-1", 10));

    assertThrows(IllegalStateException.class, () ->
        orderService.placeOrderUnchecked("ORD-1", "SKU-1"));

    // Both inventory deduction and order write were rolled back
    assertEquals(10, inventory.findById("SKU-1").get().getStock());
    assertEquals(0, orders.count());
  }

  @Test
  void checkedExceptionsCommitByDefault() {
    inventory.save(new Inventory("SKU-2", 10));

    assertThrows(Exception.class, () ->
        orderService.placeOrderChecked("ORD-2", "SKU-2"));

    // Checked exception did NOT trigger rollback under default rules: partial writes committed!
    assertEquals(9, inventory.findById("SKU-2").get().getStock());
    assertEquals(1, orders.count());
  }

  @Test
  void rollbackForExceptionRepairsCheckedExceptionCommit() {
    inventory.save(new Inventory("SKU-3", 10));

    assertThrows(Exception.class, () ->
        orderService.placeOrderRepaired("ORD-3", "SKU-3"));

    // Explicit rollbackFor rule triggers rollback on checked exceptions
    assertEquals(10, inventory.findById("SKU-3").get().getStock());
    assertEquals(0, orders.count());
  }

  @Test
  void selfInvocationBypassesServiceProxyCausingPartialCommit() {
    inventory.save(new Inventory("SKU-4", 10));

    assertThrows(IllegalStateException.class, () ->
        orderService.selfInvocationBypass("ORD-4", "SKU-4"));

    // Because service proxy was bypassed, inventory.save() committed in its own repository transaction!
    assertEquals(9, inventory.findById("SKU-4").get().getStock());
  }
}
Naming convention: OrderTransactionTest.java matches Maven Surefire default inclusion (**/*Test.java).
@EnableJpaRepositories(considerNestedRepositories = true) ensures Spring Data repository discovery inspects static nested repository interfaces.
@EntityScan(basePackageClasses = OrderTransactionTest.class) explicitly discovers static nested JPA entity classes for Hibernate schema generation.
Using Propagation.NOT_SUPPORTED on the test disables the automatic test rollback transaction, allowing assertions to query the actual persistent database state after the service call completes.
The proxy intercepts external calls, begins a transaction, and rolls back on RuntimeException by default.
By default, Spring COMMITS on checked exceptions. Use rollbackFor = Exception.class when checked exceptions require aborting the atomic transaction.
Self-invocation (this.placeOrderUnchecked()) bypasses the service-level proxy. Repository methods still execute in their own individual repository transactions, producing dangerous partial writes on failure.

Synthesis & Production Verification

Deliberate Failure Condition

  1. Break: Throw an IOException after saving an Order inside @Transactional
  2. Observe: The insert commits despite the failure
  3. Repair: Default rollback rules match RuntimeException and Error; add rollbackFor = IOException.class where checked failures must undo writes.

Production Acceptance Criteria

  • Prove rollback happens for a RuntimeException
  • Show a checked exception committing by default
  • Add rollbackFor and verify both paths behave as intended

Architectural & Technical Review

Core Interview Questions

Q01Trace the execution path of an external call to a @Transactional method.Show answer

The caller invokes the method; the Spring AOP proxy intercepts it and delegates to a TransactionInterceptor. The JpaTransactionManager obtains a connection, starts a transaction, and the target method executes. Finally, the interceptor commits or rolls back based on any thrown exceptions.

Q02Predict the database outcome if a @Transactional method throws a checked exception.Show answer

By default, Spring commits the transaction when a checked exception is thrown. The writes prior to the exception will persist unless explicit rollback rules (e.g., rollbackFor) are configured.

Q03Explain why calling a transactional method from another method within the same service class fails to open a new transaction.Show answer

Self-invocation calls the method on the `this` reference directly, bypassing the dynamic Spring AOP proxy. As a result, the transaction interceptor never executes.

Production Pitfalls

Critical Anti-Patterns

  • Pitfall: Relying on @Transactional test methods to verify rollback rules — A test method annotated with @Transactional forces a rollback at the end of the test, concealing whether the service actually committed or rolled back the data. Use isolated verification (e.g., Propagation.NOT_SUPPORTED) instead.
  • Pitfall: Applying blanket rollbackFor = Exception.class without analyzing business boundaries — Treating all checked exceptions as catastrophic failures removes the ability to intentionally save partial state (like a "pending" status) when recovering from expected alternate flows.

Primary Documentation & Specifications

Lesson Primary References