Transactions
@Transactional, propagation, isolation levels, rollback rules
Transaction Management
The @Transactional annotation uses proxy-based AOP to ensure ACID properties: propagation, isolation, and rollback rules fine-tune transactional behavior.
1. Definition
Transaction management ensures that a group of database operations executes atomically: either all succeed or all can be rolled back. Spring provides declarative transaction management through the @Transactional annotation, implemented via an AOP proxy.
Spring abstracts the transactional API through the PlatformTransactionManager interface (JPA, JDBC, JTA, R2DBC). Spring Boot auto-configures a JpaTransactionManager with the spring-boot-starter-data-jpa starter.
Service method call → AOP Proxy → begin transaction
→ target method execution
→ commit / rollback
The ACID properties:
- Atomicity: All operations succeed or all are rolled back
- Consistency: Database transitions from one consistent state to another
- Isolation: Concurrent transactions run in isolation from each other
- Durability: After commit, data is permanently persisted
2. Core Concepts
@Transactional basics
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentService paymentService;
@Transactional
public Order createOrder(OrderRequest request) {
Order order = new Order(request);
orderRepository.save(order);
paymentService.processPayment(order); // If exception here → rollback
return order;
}
@Transactional(readOnly = true)
public Order getOrder(Long id) {
return orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
}
}
readOnly = true tells Hibernate not to create a dirty checking snapshot — this is a performance optimization, and some databases (PostgreSQL) start a read-only transaction.
Propagation — transaction spreading
| Propagation | Behavior |
|---|---|
| REQUIRED (default) | Joins existing or starts new |
| REQUIRES_NEW | Always starts new, suspends existing |
| NESTED | Nested transaction with savepoint |
| SUPPORTS | Runs in TX if one exists, otherwise without |
| NOT_SUPPORTED | Always runs without TX |
| MANDATORY | Existing TX required, otherwise exception |
| NEVER | TX must not exist, otherwise exception |
@Transactional(propagation = Propagation.REQUIRED) // default
public void processOrder(Order order) { ... }
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void sendNotification(Order order) { ... }
@Transactional(propagation = Propagation.MANDATORY)
public void updateInventory(Order order) { ... }
Isolation — isolation levels
| Level | Dirty Read | Non-Repeatable Read | Phantom Read |
|---|---|---|---|
| READ_UNCOMMITTED | ✅ Possible | ✅ Possible | ✅ Possible |
| READ_COMMITTED | ❌ Protected | ✅ Possible | ✅ Possible |
| REPEATABLE_READ | ❌ Protected | ❌ Protected | ✅ Possible |
| SERIALIZABLE | ❌ Protected | ❌ Protected | ❌ Protected |
@Transactional(isolation = Isolation.READ_COMMITTED)
public void transferMoney(Long fromId, Long toId, BigDecimal amount) { ... }
@Transactional(isolation = Isolation.SERIALIZABLE)
public void criticalOperation() { ... }
The default isolation level is database-specific (PostgreSQL: READ_COMMITTED, MySQL InnoDB: REPEATABLE_READ).
Rollback rules
// Default: RuntimeException → rollback, checked exception → NO rollback
@Transactional
public void defaultRollback() { ... }
// Explicit rollback rules:
@Transactional(rollbackFor = Exception.class)
public void rollbackOnAnyException() { ... }
@Transactional(rollbackFor = PaymentException.class,
noRollbackFor = NotificationException.class)
public void selectiveRollback() { ... }
3. Practical Usage
The proxy mechanism
@Transactional works through an AOP proxy. Spring creates a CGLIB proxy around the bean:
Caller → CGLIB Proxy → TransactionInterceptor → Target method
↓
begin TX / commit / rollback
The self-invocation trap:
@Service
public class OrderService {
// ❌ BAD: this.internalMethod() bypasses the proxy!
@Transactional
public void process() {
internalMethod(); // NOT transactional!
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void internalMethod() {
// REQUIRES_NEW does NOT take effect!
}
}
Solutions for self-invocation:
// 1. Move to a separate service
@Service
public class OrderService {
private final InternalOrderService internalService;
@Transactional
public void process() {
internalService.internalMethod(); // Goes through proxy!
}
}
// 2. Self-injection (not ideal, but works)
@Service
public class OrderService {
@Lazy @Autowired
private OrderService self;
@Transactional
public void process() {
self.internalMethod(); // Goes through proxy!
}
}
TransactionTemplate for manual control
@Service
public class OrderService {
private final TransactionTemplate txTemplate;
public OrderService(PlatformTransactionManager txManager) {
this.txTemplate = new TransactionTemplate(txManager);
this.txTemplate.setIsolationLevel(
TransactionDefinition.ISOLATION_READ_COMMITTED);
}
public Order createOrder(OrderRequest request) {
return txTemplate.execute(status -> {
Order order = new Order(request);
orderRepository.save(order);
if (paymentFailed) {
status.setRollbackOnly();
}
return order;
});
}
}
Testing with transactions
@SpringBootTest
@Transactional // Rollback after every test!
class OrderServiceTest {
@Autowired
private OrderService orderService;
@Test
void shouldCreateOrder() {
Order order = orderService.createOrder(new OrderRequest(...));
assertNotNull(order.getId());
// Automatic rollback at end of test → DB is not dirty
}
@Test
@Commit // When explicit commit is needed (rare)
void shouldPersistOrder() { ... }
}
4. Code Examples
REQUIRES_NEW — independent transaction
@Service
public class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAuditEvent(String event, String details) {
auditRepository.save(new AuditEvent(event, details));
// This ALWAYS commits, even if the caller's transaction
// rolls back — the audit log is preserved
}
}
@Service
public class OrderService {
private final AuditService auditService;
private final OrderRepository orderRepository;
@Transactional
public void processOrder(Order order) {
orderRepository.save(order);
auditService.logAuditEvent("ORDER_CREATED", order.getId().toString());
// If exception here → order rollback, but audit remains
riskyOperation();
}
}
Timeout and readOnly
@Transactional(timeout = 5) // 5 second timeout
public void longRunningOperation() {
// If it takes longer than 5s → TransactionTimedOutException
}
@Transactional(readOnly = true)
public List<OrderDto> generateReport(ReportCriteria criteria) {
// readOnly:
// 1. Hibernate skips dirty checking snapshot
// 2. Some DBs start a read-only transaction (optimization)
// 3. Flush mode: MANUAL (no automatic flush)
return orderRepository.findByCriteria(criteria)
.stream().map(OrderDto::from).toList();
}
Event-based transactional pattern
@Service
public class OrderService {
private final ApplicationEventPublisher publisher;
@Transactional
public Order createOrder(OrderRequest request) {
Order order = orderRepository.save(new Order(request));
publisher.publishEvent(new OrderCreatedEvent(order));
return order;
}
}
@Component
public class OrderEventListener {
// Runs only after successful COMMIT:
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderCreated(OrderCreatedEvent event) {
emailService.sendOrderConfirmation(event.getOrder());
}
// Runs on rollback:
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void onOrderFailed(OrderCreatedEvent event) {
alertService.notifyOrderFailure(event.getOrder());
}
}
Programmatic rollback
@Transactional
public void processPayment(Payment payment) {
paymentRepository.save(payment);
if (!externalGateway.validate(payment)) {
// Programmatic rollback request:
TransactionAspectSupport.currentTransactionStatus()
.setRollbackOnly();
return;
}
externalGateway.charge(payment);
}
5. Trade-offs
| Advantage | Disadvantage |
|---|---|
| Declarative (@Transactional) — clean code | Proxy-based → self-invocation trap |
| ACID guarantee automatically | Performance overhead (commit/rollback) |
| Propagation flexibility | REQUIRES_NEW: connection pool exhaustion risk |
| readOnly optimization | Hidden behavior (rollback rules) |
| @TransactionalEventListener | Isolation level + lock → deadlock risk |
When to use declarative (@Transactional)
- Service layer methods (standard use case)
- CRUD operations where ACID guarantees matter
- Read-only queries (readOnly = true)
When to use programmatic (TransactionTemplate)
- Fine-grained transaction control
- Multiple transactions within a single method
- Dynamic isolation level
6. Common Mistakes
❌ Self-invocation — bypassing the proxy
@Service
public class OrderService {
// BAD: this.validate() bypasses the proxy
@Transactional
public void process(Order order) {
validate(order); // NOT transactional!
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void validate(Order order) { ... }
}
Solution: Move to a separate bean, or use self-injection.
❌ Checked exceptions don't trigger rollback
// BAD: IOException does not cause rollback
@Transactional
public void importData(InputStream is) throws IOException {
// IOException (checked) → NO rollback!
parseAndSave(is);
}
// GOOD: explicit rollbackFor
@Transactional(rollbackFor = Exception.class)
public void importData(InputStream is) throws IOException {
parseAndSave(is);
}
❌ @Transactional on private methods
// BAD: CGLIB proxy cannot override private methods
@Transactional
private void updateOrder(Order order) { ... } // No effect!
// GOOD: public method
@Transactional
public void updateOrder(Order order) { ... }
❌ Overusing REQUIRES_NEW
// BAD: every method REQUIRES_NEW → many connections held
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void step1() { ... }
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void step2() { ... }
// GOOD: REQUIRED (default) is sufficient in most cases
@Transactional
public void step1() { ... }
❌ Swallowing exceptions in catch blocks
// BAD: catch block prevents rollback
@Transactional
public void process() {
try {
riskyOperation();
} catch (RuntimeException e) {
log.error("Failed", e);
// Transaction does NOT rollback because exception is not propagated!
}
}
// GOOD: re-throw or setRollbackOnly()
@Transactional
public void process() {
try {
riskyOperation();
} catch (RuntimeException e) {
log.error("Failed", e);
throw e; // Rollback happens
}
}
7. Deep Dive
The complete proxy mechanism
- Spring
BeanPostProcessordetects the@Transactionalannotation - Creates a CGLIB proxy around the bean (class-level proxy)
TransactionInterceptorreceives the method call- Checks
@Transactionalattributes (propagation, isolation, rollback) PlatformTransactionManager.getTransaction()— start/join transaction- Target method invocation
- Success →
commit(), RuntimeException →rollback()
Propagation internals
Caller → REQUIRED → Joins existing TX OR starts new
→ REQUIRES_NEW → Suspends current TX, starts new TX
→ NESTED → Savepoint in current TX
→ SUPPORTS → Runs in TX if exists, otherwise without
→ MANDATORY → Existing TX required, otherwise exception
REQUIRES_NEW holds two DB connections: the suspended + the new transaction. If the pool size is small, deadlock or timeout may occur.
Isolation levels in detail
Dirty Read: Reading another transaction's uncommitted modification. Non-Repeatable Read: Reading the same row twice yields different values (another TX committed). Phantom Read: Running the same query twice returns different rows (another TX inserted).
READ_UNCOMMITTED → Fastest, least safe
READ_COMMITTED → Most common (PostgreSQL default)
REPEATABLE_READ → MySQL InnoDB default
SERIALIZABLE → Slowest, safest
@Transactional vs TransactionTemplate vs TransactionManager
| Approach | Type | When |
|---|---|---|
@Transactional |
Declarative | 90% of cases — service methods |
TransactionTemplate |
Programmatic | Fine granularity, multiple TX in one method |
PlatformTransactionManager |
Low-level | Custom framework / interceptor |
Spring Boot auto-configured transaction managers
| Starter | TransactionManager |
|---|---|
spring-boot-starter-data-jpa |
JpaTransactionManager |
spring-boot-starter-jdbc |
DataSourceTransactionManager |
spring-boot-starter-data-r2dbc |
R2dbcTransactionManager |
8. Interview Questions
How does the @Transactional annotation work? Through an AOP proxy: Spring creates a CGLIB proxy around the bean, the TransactionInterceptor begins/commits/rolls back the transaction around the target method call.
What is the self-invocation problem and how do you solve it? When a @Transactional method calls another method on the same class (
this.method()), it bypasses the proxy — the transactional annotation has no effect. Solution: move to a separate bean, or self-injection.What is the difference between REQUIRED and REQUIRES_NEW propagation? REQUIRED: joins existing transaction or starts new. REQUIRES_NEW: always starts a new transaction, suspends the existing one. REQUIRES_NEW holds two DB connections.
When does @Transactional NOT roll back? By default, checked exceptions do not cause rollback. Only RuntimeException and Error trigger rollback. Explicit:
rollbackFor = Exception.class.What is the effect of readOnly = true? Hibernate skips dirty checking snapshots, flush mode is set to MANUAL. Some databases start a read-only transaction (PostgreSQL optimization).
What is @TransactionalEventListener used for? Event handling bound to transaction phases: AFTER_COMMIT (only after successful commit), AFTER_ROLLBACK, BEFORE_COMMIT. Typical: sending emails, notifications.
Why doesn't @Transactional work on private methods? The CGLIB proxy cannot override private methods. The transaction interceptor only takes effect on public methods.
9. Glossary
| Term | Meaning |
|---|---|
| @Transactional | Declarative transaction management annotation |
| Propagation | Transaction spreading behavior (REQUIRED, REQUIRES_NEW, etc.) |
| Isolation | Concurrent transaction isolation level |
| Rollback | Transaction rollback on error |
| ACID | Atomicity, Consistency, Isolation, Durability |
| Self-invocation | Bypassing the proxy through internal method call |
| PlatformTransactionManager | Spring transactional API abstraction |
| TransactionTemplate | Programmatic transaction management helper |
| readOnly | Signal to ORM and DB that no writes will occur |
| Savepoint | Internal checkpoint for NESTED propagation |
| @TransactionalEventListener | Event handler bound to transaction phase |
| CGLIB Proxy | Class-level proxy implementing @Transactional |
10. Cheatsheet
@TRANSACTIONAL ATTRIBUTES:
propagation REQUIRED (default), REQUIRES_NEW, NESTED, SUPPORTS
isolation DEFAULT, READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE
readOnly true/false (default: false)
timeout in seconds (default: -1 = none)
rollbackFor Exception class(es) → rollback
noRollbackFor Exception class(es) → NO rollback
ROLLBACK RULES:
RuntimeException → rollback (default)
Checked Exception → NO rollback (default)
rollbackFor = Exception.class → ALL exceptions rollback
PROPAGATION:
REQUIRED joins existing OR starts new
REQUIRES_NEW always new TX, existing suspended
NESTED savepoint in existing TX
MANDATORY existing TX required, otherwise exception
SELF-INVOCATION:
this.method() bypasses proxy → ❌
injectedSelf.method() goes through proxy → ✅
separateBean.method() goes through proxy → ✅
TESTING:
@Transactional on test → automatic rollback
@Commit → explicit commit (rare)
EVENT LISTENER:
@TransactionalEventListener(phase = AFTER_COMMIT)
@TransactionalEventListener(phase = AFTER_ROLLBACK)
@TransactionalEventListener(phase = BEFORE_COMMIT)
🎮 Games
10 questions