Advanced Reading time: ~11 min

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

  1. Spring BeanPostProcessor detects the @Transactional annotation
  2. Creates a CGLIB proxy around the bean (class-level proxy)
  3. TransactionInterceptor receives the method call
  4. Checks @Transactional attributes (propagation, isolation, rollback)
  5. PlatformTransactionManager.getTransaction() — start/join transaction
  6. Target method invocation
  7. 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

  1. 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.

  2. 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.

  3. 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.

  4. When does @Transactional NOT roll back? By default, checked exceptions do not cause rollback. Only RuntimeException and Error trigger rollback. Explicit: rollbackFor = Exception.class.

  5. 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).

  6. 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.

  7. 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