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
Lesson 13 of 17
1d streakThe 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.
- 01Caller invokes
OrderService.placeOrder(). - 02The Spring AOP Proxy intercepts the call and delegates to a
TransactionInterceptor. - 03The
JpaTransactionManagerobtains a physical database connection from the connection pool (e.g., HikariCP) and begins a database transaction. - 04The actual target method executes its business logic and repository queries.
- 05If the method completes normally, the transaction commits and the connection returns to the pool.
- 06If an unhandled exception propagates out of the method, the interceptor may roll back the transaction.
Logical scopes vs physical resources
@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
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.
Working Implementation
Code Walkthrough
Create a service method that saves an order, then intentionally throws a RuntimeException. Verify the order is rolled back.
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());
}
}Synthesis & Production Verification
Deliberate Failure Condition
- Break: Throw an IOException after saving an Order inside @Transactional
- Observe: The insert commits despite the failure
- 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
Key Takeaway
Predict which rows remain after a checked or unchecked exception under the stated rollback rules, and verify the prediction using an isolated test connection.
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
Supplementary Module Resources