Handling Database Transactions with Spring's @Transactional Annotation
An online checkout often changes several records: it creates an order, reduces stock and records a payment reference. If one operation fails, the database must not retain a half-finished order. @Transactional provides that atomic boundary around the work performed by a Spring-managed method.
Spring implements this through a proxy. When the proxy intercepts a qualifying call, it begins a database transaction before the method and commits or rolls it back afterwards. Calls that bypass the proxy fall outside that control, which makes method placement and bean design essential.
A useful boundary covers related SQL operations, not unrelated external work. Persisting an order and updating stock belong together; charging a card through a remote payment gateway does not. Treating both as one atomic unit can hold database locks far longer than intended.
Reliable handling also requires matching propagation, isolation, rollback rules and transaction managers to the application's behaviour. These choices matter when Australian retailers serve customers in Sydney and Melbourne, where ordering systems and fulfilment teams often operate across different time zones.
Why transaction boundaries matter
Each SQL statement is atomic, but a business transaction usually spans several statements. The service layer should define one clear boundary around the complete database change. Repositories may have their own transactions; the service-level transaction then coordinates their work.
Concurrency needs separate consideration. Two shoppers can otherwise buy the same final item. Row locking, suitable isolation or an optimistic @Version field can prevent lost updates. The appropriate choice depends on throughput, database engine and how costly retries are.
Building a transactional service
Place @Transactional on a public method of a Spring-managed service, rather than on a controller or isolated helper. Both the order and product repositories must use the same transaction manager and data source for their changes to participate in one unit of work.
@Service
public class OrderService {
@Transactional
public Order placeOrder(Long productId, int quantity) {
Product product = productRepository.findById(productId)
.orElseThrow(() ->
new IllegalArgumentException("Unknown product"));
if (product.getStock() < quantity) {
throw new IllegalStateException("Insufficient stock");
}
product.setStock(product.getStock() - quantity);
productRepository.save(product);
return orderRepository.save(new Order(product, quantity));
}
}
Here, an unchecked exception rolls back both writes when the method exits unsuccessfully. A successful return commits them together, so observers never see reduced stock without its corresponding order.
Controlling rollback behaviour
By default, Spring rolls back on unchecked RuntimeException and Error signals. A checked exception, such as a checked IOException, does not trigger rollback unless configured explicitly. Use @Transactional(rollbackFor = Exception.class) when every failure must undo the transaction.
Never swallow an exception merely to let the transaction commit. If compensation is needed, record the failure and apply a separate, auditable business action. A timeout can bound a slow transaction, but it cannot make external work reversible.
Choosing propagation and isolation
Propagation controls how an inner call relates to an existing transaction. REQUIRED, the default, joins it or starts one. REQUIRES_NEW suspends the current transaction and creates an independent unit, which can keep an audit record when the outer transaction fails. NESTED relies on savepoint support and behaves differently across database engines.
Isolation determines what concurrent transactions can see. The database default is often sufficient, although MySQL and PostgreSQL have different defaults. READ_COMMITTED is a common web-application baseline, while SERIALIZABLE can prevent anomalies but trigger serialization failures that callers must retry.
Avoiding proxy and configuration traps
Spring's proxy cannot intercept a method that calls itself internally. Move transactional work into another bean or redesign the operation. Private and final methods are also poor choices because they prevent common proxy-based interception.
Applications with multiple data sources need an explicit transactionManager value when the default is ambiguous. JPA and JDBC can share a JpaTransactionManager when they use the same DataSource; deliberately separate managers can split work that should remain atomic. readOnly=true is a query hint, not a write barrier or security control.
Applying Australian operational and legal context
Australian operations add practical constraints. Sydney and Melbourne retailers settle AUD orders and record GST on eligible invoices, while the ATO generally expects business records to be kept for five years. Transaction histories should remain auditable even when a later correction changes the current order.
The Privacy Act 1988 and Australian Privacy Principles shape customer-data handling for covered organisations. For customers checking mobile-banking apps, a rolled-back order must not imply that a captured card payment disappeared. Handle refunds separately, honouring applicable Australian Consumer Law rights, using idempotent requests and an auditable event trail.
Patterns and cautions for production
The first checklist favours explicit boundaries and observable guarantees. The second highlights failures that can survive code review because they initially look harmless.
Patterns worth adopting
- Keep the boundary in a public service method and call other beans through their injected references.
- Configure rollback explicitly when checked exceptions can signal a failed business operation.
- Set a sensible timeout and verify contention behaviour against the production database engine.
- Use integration tests that prove an induced failure leaves every affected table unchanged.
Failure points to avoid
- Calling a transactional method from inside the same bean, which bypasses the proxy.
- Catching an exception and returning normally, allowing a partial commit.
- Holding a transaction open during a slow payment-gateway or email request.
- Assuming
readOnly=trueprevents writes in every persistence provider.
A transaction is reliable only when its data, failure handling and audit trail agree. That agreement matters more than simply adding the annotation.