Spring Boot
Transactions
Spring Boot auto-configures transaction management through @EnableTransactionManagement and a PlatformTransactionManager. @Transactional declaratively demarcates transaction boundaries — Spring wraps the annotated method in a proxy that opens, commits, or rolls back the transaction. This entry covers @Transactional attributes, propagation, isolation levels, rollback rules, read-only optimisations, programmatic transactions, and common pitfalls.
@Transactional Basics
@Transactional on a public service method tells Spring to open a transaction before the method runs and commit it on normal return, or roll back on an unchecked exception. Place it on the service layer — never on the repository layer (Spring Data already manages transactions there) and never on controllers.
Java
// ── Service layer — correct placement ────────────────────────────────
@Service
@RequiredArgsConstructor
@Slf4j
public class OrderService {
private final OrderRepository orderRepo;
private final InventoryService inventoryService;
private final PaymentService paymentService;
private final NotificationService notificationService;
// ── Single unit of work — all-or-nothing ──────────────────────────
@Transactional
public OrderResponse placeOrder(PlaceOrderRequest request) {
// 1. Validate and reserve inventory
inventoryService.reserve(request.items());
// 2. Persist the order
Order order = orderRepo.save(Order.from(request));
// 3. Charge the customer
paymentService.charge(order);
// 4. If any step throws a RuntimeException, all steps roll back
return OrderResponse.from(order);
}
// ── Read-only query — no transaction needed for a single query,
// but @Transactional(readOnly=true) is useful for multi-query
// methods that must see a consistent snapshot ─────────────────
@Transactional(readOnly = true)
public OrderResponse findById(Long id) {
return orderRepo.findById(id)
.map(OrderResponse::from)
.orElseThrow(() -> new OrderNotFoundException(id));
}
}
// ── What NOT to do ────────────────────────────────────────────────────
// WRONG — @Transactional on a controller:
@RestController
public class OrderController {
@Transactional // ← never here; HTTP layer should not own tx
@PostMapping("/orders")
public ResponseEntity<OrderResponse> create(...) { ... }
}
// WRONG — @Transactional on a private method:
@Service
public class UserService {
@Transactional // ← ignored; Spring proxy cannot intercept
private void updateInternal(User user) { ... }
}
// WRONG — @Transactional on a repository (Spring Data handles this):
public interface OrderRepository extends JpaRepository<Order, Long> {
@Transactional // ← redundant; already transactional
Optional<Order> findById(Long id);
}Propagation
Propagation controls what happens when a @Transactional method is called from within an existing transaction. REQUIRED (the default) joins an existing transaction or creates a new one. REQUIRES_NEW always creates a new independent transaction, suspending the outer one. NESTED creates a savepoint inside the outer transaction. The remaining levels are used for edge cases.
Java
@Service
@RequiredArgsConstructor
@Slf4j
public class AuditService {
private final AuditLogRepository auditLogRepo;
// ── REQUIRED (default) — join outer tx or create a new one ────────
@Transactional(propagation = Propagation.REQUIRED)
public void log(String action, Long entityId) {
auditLogRepo.save(new AuditLog(action, entityId));
// If called from within an existing tx → joins it
// If called standalone → opens its own tx
}
// ── REQUIRES_NEW — always a new independent transaction ───────────
// Use for audit logs, notifications, or any operation that must
// succeed or fail independently of the outer transaction.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logIndependent(String action, Long entityId) {
auditLogRepo.save(new AuditLog(action, entityId));
// Outer tx is SUSPENDED while this runs.
// Commits even if the outer tx rolls back.
// Rolls back independently of the outer tx.
}
// ── NESTED — savepoint inside the outer tx ────────────────────────
// Roll back to the savepoint on failure without losing the outer tx.
// Only supported with JDBC transactions (not JTA).
@Transactional(propagation = Propagation.NESTED)
public void logNested(String action, Long entityId) {
auditLogRepo.save(new AuditLog(action, entityId));
}
// ── SUPPORTS — join tx if one exists, else run non-transactionally ─
@Transactional(propagation = Propagation.SUPPORTS)
public List<AuditLog> findAll() {
return auditLogRepo.findAll();
}
// ── NOT_SUPPORTED — suspend any active tx, run non-transactionally ─
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void runWithoutTransaction(Runnable task) {
task.run();
}
// ── NEVER — throw if called within an active transaction ──────────
@Transactional(propagation = Propagation.NEVER)
public void mustRunOutsideTransaction() {
// Throws IllegalTransactionStateException if a tx is active
}
// ── MANDATORY — throw if there is no active transaction ───────────
@Transactional(propagation = Propagation.MANDATORY)
public void mustRunInsideTransaction() {
// Throws IllegalTransactionStateException if no tx is active
}
}
// ── Propagation interaction diagram ───────────────────────────────────
//
// outer tx active? REQUIRED REQUIRES_NEW NESTED
// ───────────────── ────────── ──────────── ──────────────
// Yes Join outer New tx Savepoint
// No New tx New tx New txIsolation Levels
Isolation controls which uncommitted changes from concurrent transactions are visible within the current transaction. Higher isolation prevents more anomalies but increases lock contention. Choose the lowest isolation level that satisfies your consistency requirements. Spring Boot defaults to the database default — READ_COMMITTED for PostgreSQL and MySQL.
Java
@Service
@RequiredArgsConstructor
public class ReportService {
private final OrderRepository orderRepo;
private final AccountRepository accountRepo;
// ── READ_UNCOMMITTED — sees dirty reads ───────────────────────────
// Can read rows modified by uncommitted transactions.
// Rarely correct — only for approximate counts or caches.
@Transactional(isolation = Isolation.READ_UNCOMMITTED)
public long approximateOrderCount() {
return orderRepo.count();
}
// ── READ_COMMITTED (PostgreSQL / MySQL default) ───────────────────
// Sees only committed data; prevents dirty reads.
// Subject to non-repeatable reads: re-reading the same row within
// one transaction may return different values if another tx committed
// between the two reads.
@Transactional(isolation = Isolation.READ_COMMITTED)
public OrderSummary summarise(Long orderId) {
return orderRepo.findById(orderId)
.map(OrderSummary::from)
.orElseThrow(() -> new OrderNotFoundException(orderId));
}
// ── REPEATABLE_READ — consistent row reads ────────────────────────
// Prevents dirty reads and non-repeatable reads.
// A row read twice in the same transaction always returns the same
// values. Subject to phantom reads (new rows inserted by other tx).
@Transactional(isolation = Isolation.REPEATABLE_READ)
public FinancialReport buildReport(LocalDate date) {
// Reading totals multiple times will return consistent values
BigDecimal revenue = orderRepo.totalRevenueFor(date);
BigDecimal refunds = orderRepo.totalRefundsFor(date);
return new FinancialReport(date, revenue, refunds);
}
// ── SERIALIZABLE — full isolation ─────────────────────────────────
// Prevents dirty reads, non-repeatable reads, and phantom reads.
// Transactions execute as if serial — highest consistency,
// highest lock contention. Use only when correctness demands it.
@Transactional(isolation = Isolation.SERIALIZABLE)
public void transferFunds(Long fromId, Long toId,
BigDecimal amount) {
Account from = accountRepo.findById(fromId)
.orElseThrow();
Account to = accountRepo.findById(toId)
.orElseThrow();
from.debit(amount);
to.credit(amount);
accountRepo.save(from);
accountRepo.save(to);
}
}
// ── Isolation anomaly matrix ───────────────────────────────────────────
//
// Level Dirty Read Non-Repeatable Phantom Read
// ──────────────────────────────────────────────────────────────
// READ_UNCOMMITTED Possible Possible Possible
// READ_COMMITTED Prevented Possible Possible
// REPEATABLE_READ Prevented Prevented Possible
// SERIALIZABLE Prevented Prevented PreventedRollback Rules
By default Spring rolls back on RuntimeException and Error but commits on checked exceptions. Override this with rollbackFor and noRollbackFor. Always throw unchecked exceptions from service methods — or explicitly configure rollbackFor for checked exceptions — to ensure the transaction rolls back on failure.
Java
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentRepository paymentRepo;
private final LedgerRepository ledgerRepo;
// ── Default: rollback on RuntimeException and Error ───────────────
@Transactional
public PaymentResponse process(ProcessPaymentRequest request) {
Payment payment = paymentRepo.save(Payment.from(request));
ledgerRepo.record(payment); // RuntimeException → rollback
return PaymentResponse.from(payment);
}
// ── Rollback on a checked exception ───────────────────────────────
@Transactional(rollbackFor = PaymentGatewayException.class)
public PaymentResponse processExternal(ProcessPaymentRequest request)
throws PaymentGatewayException {
Payment payment = paymentRepo.save(Payment.from(request));
externalGateway.charge(payment); // checked — rolls back
return PaymentResponse.from(payment);
}
// ── Rollback on multiple exception types ──────────────────────────
@Transactional(rollbackFor = {
PaymentGatewayException.class,
InsufficientFundsException.class,
FraudDetectedException.class
})
public void processWithCheckedExceptions(Long paymentId)
throws PaymentGatewayException,
InsufficientFundsException,
FraudDetectedException {
Payment payment = paymentRepo.findById(paymentId).orElseThrow();
fraudService.check(payment);
gatewayService.charge(payment);
}
// ── Suppress rollback for a specific RuntimeException ─────────────
// Use when a known business exception should commit the transaction
// (e.g. recording a failed payment attempt before throwing):
@Transactional(noRollbackFor = PaymentDeclinedException.class)
public void recordAndThrowDeclined(Long paymentId) {
Payment payment = paymentRepo.findById(paymentId).orElseThrow();
payment.markDeclined();
paymentRepo.save(payment); // committed even though we throw
throw new PaymentDeclinedException("Card declined");
}
// ── Mark transaction for rollback programmatically ─────────────────
@Transactional
public void processWithManualRollback(Long paymentId) {
try {
Payment payment = paymentRepo.findById(paymentId).orElseThrow();
riskyOperation(payment);
} catch (SomeRecoverableException ex) {
// Log and mark for rollback without re-throwing
log.warn("Marking transaction for rollback: {}", ex.getMessage());
TransactionAspectSupport.currentTransactionStatus()
.setRollbackOnly();
}
}
}Read-Only Transactions
@Transactional(readOnly = true) signals the transaction manager and Hibernate that no data will be modified. Hibernate skips dirty checking on all loaded entities — reducing CPU and memory overhead. The database driver may also route the query to a read replica when using a routing data source. Apply it to every query-only service method.
Java
@Service
@RequiredArgsConstructor
public class ProductQueryService {
private final ProductRepository productRepo;
// ── Single entity lookup ───────────────────────────────────────────
@Transactional(readOnly = true)
public ProductResponse findById(Long id) {
return productRepo.findById(id)
.map(ProductResponse::from)
.orElseThrow(() -> new ProductNotFoundException(id));
}
// ── Paginated list ─────────────────────────────────────────────────
@Transactional(readOnly = true)
public Page<ProductResponse> findAll(Pageable pageable) {
return productRepo.findAll(pageable)
.map(ProductResponse::from);
}
// ── Projection query ───────────────────────────────────────────────
@Transactional(readOnly = true)
public List<ProductSummary> findSummaries() {
return productRepo.findAllProjectedBy();
}
// ── Multi-query report — consistent snapshot across queries ────────
@Transactional(readOnly = true)
public DashboardData buildDashboard() {
long total = productRepo.count();
long active = productRepo.countByStatus(ProductStatus.ACTIVE);
long outStock = productRepo.countByStockQuantity(0);
BigDecimal avgPrice = productRepo.averagePrice();
return new DashboardData(total, active, outStock, avgPrice);
}
}
// ── Class-level readOnly with write method override ───────────────────
// Mark the whole service read-only, then override write methods:
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true) // default for all methods
public class CategoryService {
private final CategoryRepository categoryRepo;
public List<CategoryResponse> findAll() { // inherits readOnly=true
return categoryRepo.findAll()
.stream().map(CategoryResponse::from).toList();
}
public CategoryResponse findById(Long id) { // inherits readOnly=true
return categoryRepo.findById(id)
.map(CategoryResponse::from)
.orElseThrow(() -> new CategoryNotFoundException(id));
}
@Transactional // overrides: readOnly=false
public CategoryResponse create(CreateCategoryRequest req) {
return CategoryResponse.from(
categoryRepo.save(Category.from(req)));
}
@Transactional // overrides: readOnly=false
public void delete(Long id) {
categoryRepo.deleteById(id);
}
}Programmatic Transactions
When declarative @Transactional is not flexible enough — for example, to commit mid-method, to roll back only part of an operation, or to manage transactions in a loop — use TransactionTemplate or PlatformTransactionManager directly. TransactionTemplate is the simpler of the two for most cases.
Java
@Service
@RequiredArgsConstructor
@Slf4j
public class BatchImportService {
private final TransactionTemplate txTemplate;
private final ProductRepository productRepo;
private final PlatformTransactionManager txManager;
// ── TransactionTemplate — commit per batch ─────────────────────────
public ImportResult importProducts(List<ProductCsvRow> rows) {
int imported = 0;
int failed = 0;
// Split into batches of 100, commit each batch independently
List<List<ProductCsvRow>> batches = partition(rows, 100);
for (List<ProductCsvRow> batch : batches) {
try {
Integer count = txTemplate.execute(status -> {
List<Product> products = batch.stream()
.map(Product::from)
.toList();
productRepo.saveAll(products);
return products.size();
});
imported += count != null ? count : 0;
} catch (Exception ex) {
log.warn("Batch failed, skipping: {}", ex.getMessage());
failed += batch.size();
}
}
return new ImportResult(imported, failed);
}
// ── TransactionTemplate with manual rollback ───────────────────────
public void processWithPartialRollback(Long orderId) {
txTemplate.execute(status -> {
try {
processOrder(orderId);
} catch (RecoverableException ex) {
log.warn("Rolling back order {}: {}", orderId, ex.getMessage());
status.setRollbackOnly(); // mark for rollback explicitly
}
return null;
});
}
// ── PlatformTransactionManager — full manual control ──────────────
public void manualTransaction(Runnable work) {
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
def.setPropagationBehavior(
TransactionDefinition.PROPAGATION_REQUIRED);
def.setIsolationLevel(
TransactionDefinition.ISOLATION_READ_COMMITTED);
def.setTimeout(30); // 30-second timeout
TransactionStatus status = txManager.getTransaction(def);
try {
work.run();
txManager.commit(status);
} catch (Exception ex) {
txManager.rollback(status);
throw ex;
}
}
}
// ── Configure TransactionTemplate as a bean ───────────────────────────
@Configuration
public class TransactionConfig {
@Bean
public TransactionTemplate transactionTemplate(
PlatformTransactionManager txManager) {
TransactionTemplate template = new TransactionTemplate(txManager);
template.setIsolationLevel(
TransactionDefinition.ISOLATION_READ_COMMITTED);
template.setTimeout(60);
return template;
}
}Common Pitfalls
Most transaction bugs in Spring Boot fall into a small set of patterns: self-invocation bypassing the proxy, @Transactional on private methods, swallowing exceptions that should trigger rollback, and lazy loading outside a transaction. Knowing these pitfalls prevents hours of debugging.
Java
// ── Pitfall 1: Self-invocation bypasses the proxy ────────────────────
@Service
public class OrderService {
@Transactional
public void placeOrder(PlaceOrderRequest req) {
// ... order logic
}
public void placeOrderAndNotify(PlaceOrderRequest req) {
placeOrder(req); // ← direct call — proxy NOT invoked
// @Transactional on placeOrder() is silently ignored here
}
}
// FIX — inject self, or restructure so the caller is outside the class:
@Service
@RequiredArgsConstructor
public class OrderService {
@Lazy
@Autowired
private OrderService self; // proxy-injected self-reference
public void placeOrderAndNotify(PlaceOrderRequest req) {
self.placeOrder(req); // ← goes through the proxy
}
@Transactional
public void placeOrder(PlaceOrderRequest req) { ... }
}
// ── Pitfall 2: @Transactional on private methods ─────────────────────
@Service
public class UserService {
@Transactional // ← silently ignored — proxy cannot intercept
private void updateInternal(User user) { ... }
}
// FIX — make the method public or package-private.
// ── Pitfall 3: Swallowing the exception ───────────────────────────────
@Transactional
public void process(Long id) {
try {
riskyOperation(id);
} catch (RuntimeException ex) {
log.error("Failed", ex);
// ← exception swallowed — Spring never sees it — tx COMMITS
}
}
// FIX — re-throw, or call TransactionAspectSupport
// .currentTransactionStatus().setRollbackOnly():
@Transactional
public void process(Long id) {
try {
riskyOperation(id);
} catch (RuntimeException ex) {
log.error("Failed", ex);
TransactionAspectSupport.currentTransactionStatus()
.setRollbackOnly();
}
}
// ── Pitfall 4: LazyInitializationException outside transaction ────────
@Service
public class OrderService {
@Transactional(readOnly = true)
public Order findById(Long id) {
return orderRepo.findById(id).orElseThrow();
// Order.items is LAZY — OK inside @Transactional
}
}
@RestController
public class OrderController {
@GetMapping("/{id}")
public OrderResponse findById(@PathVariable Long id) {
Order order = orderService.findById(id);
order.getItems().size(); // ← LazyInitializationException!
// Transaction already closed when service method returned.
}
}
// FIX — fetch eagerly in the query, use a DTO projection,
// or use JOIN FETCH in the repository query.
// ── Pitfall 5: Long transactions holding locks ─────────────────────────
@Transactional
public void processAndSendEmail(Long orderId) {
Order order = orderRepo.findById(orderId).orElseThrow();
order.markProcessed();
orderRepo.save(order); // row locked
emailService.sendConfirmation(order); // ← slow external call inside tx
// Row stays locked for the entire email round-trip.
}
// FIX — commit first, then send the email outside the transaction:
@Transactional
public Order markProcessed(Long orderId) {
Order order = orderRepo.findById(orderId).orElseThrow();
order.markProcessed();
return orderRepo.save(order);
} // tx commits here, lock released
public void processAndSendEmail(Long orderId) {
Order order = markProcessed(orderId);
emailService.sendConfirmation(order); // outside tx — no lock held
}