Back to Rollback Rules & Pitfalls
Q3Question 3 of 5

Why are external side effects inside a transaction dangerous?

Key Concepts
  • @Transactional governs only database resources (JDBC connection commit and rollback)
  • External side effects like emails, SMS, HTTP calls, and Kafka publishes are NOT undone on rollback
  • Sending a confirmation SMS inside a transaction and then rolling back means the customer gets a false notification
  • Fix: use @TransactionalEventListener(phase = AFTER_COMMIT) to defer side effects until after the commit
  • AFTER_ROLLBACK listener can fire alternative actions (failure notification) if the transaction rolls back
  • For guaranteed delivery across restarts, use the Transactional Outbox pattern instead of in-memory events
  • Holding a connection during an HTTP call also wastes pool connections (separate but related problem)

Definition

@Transactional rolls back database changes but cannot roll back emails, SMS messages, HTTP calls, or any external side effect -- fire side effects only after a confirmed commit using @TransactionalEventListener(AFTER_COMMIT).
  • Transaction scope -- @Transactional manages only resources controlled by the Spring transaction manager, which in practice means the JDBC database connection.
  • External side effects -- sending an email, calling an SMS gateway, publishing to Kafka, writing a file, or making an HTTP request are all outside the transaction manager's reach.
  • Irreversible actions -- once an email is sent or an SMS is delivered, rolling back the database transaction does not unsend the email. The customer receives a notification for an operation that never happened.
  • @TransactionalEventListener -- a Spring listener that fires only after the transaction reaches a specific phase: AFTER_COMMIT (safe to fire side effects) or AFTER_ROLLBACK (fire failure notifications).
  • Transactional Outbox -- for guaranteed delivery across process restarts, write an outbox event in the same transaction and relay it asynchronously.

At the National Bank of India, a teller confirms Rajesh's transfer by phone (SMS) and then the vault fails to update (rollback). Rajesh thinks the transfer happened because he received the message, but the money never moved. The fix: only call the customer AFTER the vault confirms the update is permanent. If the vault fails, Rajesh is never called, and no false confirmation goes out.

Understanding the Concepts

Why does @Transactional only govern database resources?

@Transactional manages only resources that participate in the transaction manager's protocol. In a standard Spring Boot application, this means the JDBC database connection. When the transaction rolls back, the database undoes INSERT, UPDATE, and DELETE operations. But an email already sent, an SMS already delivered, an HTTP response already returned, or a Kafka message already published cannot be recalled by the transaction manager.

The transaction manager uses the two-phase commit protocol (begin, commit/rollback) with resources that support it. Databases support this protocol through JDBC. Email servers, SMS gateways, and REST APIs do not participate in database transactions.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class UnsafeTransferService {

    private final AccountRepository accountRepository;
    private final SmsService smsService;

    public UnsafeTransferService(AccountRepository accountRepository,
                                  SmsService smsService) {
        this.accountRepository = accountRepository;
        this.smsService = smsService;
    }

    // WRONG: SMS sent inside transaction
    @Transactional
    public void transfer(String fromId, String toId, BigDecimal amount) {
        var from = accountRepository.findByIdForUpdate(fromId).orElseThrow();
        var to = accountRepository.findByIdForUpdate(toId).orElseThrow();

        from.setBalance(from.getBalance().subtract(amount));
        to.setBalance(to.getBalance().add(amount));

        // SMS sent BEFORE commit
        smsService.send(from.getPhone(),
            "Rs." + amount + " transferred to " + toId);

        // If an exception occurs AFTER the SMS...
        // or if the commit fails (DB crash, constraint violation)...
        // the SMS was already delivered!
        // Rajesh thinks Rs.50,000 moved, but it did not.
    }
}
TEXT
Failure scenario:
  1. Debit SAV2001: balance 500000 -> 450000 (in persistence context)
  2. Credit CUR3001: balance 100000 -> 150000 (in persistence context)
  3. SMS sent: "Rs.50,000 transferred to CUR3001"
  4. Commit fails (DB constraint violation or network timeout)
  5. ROLLBACK: SAV2001 = 500000, CUR3001 = 100000 (unchanged)
  6. SMS already delivered -- Rajesh thinks money moved
  7. Priya calls: "Where is the money?"
  8. Reconciliation break: SMS says success, DB says nothing happened

How do you fix it with @TransactionalEventListener?

The fix is to publish a domain event inside the transaction and handle it with a @TransactionalEventListener that fires only after the transaction commits. Spring holds the event and delivers it to the listener only when the outcome is known.

JAVA
package com.bank.event;

import java.math.BigDecimal;

public record TransferCompletedEvent(
    String fromId,
    String toId,
    BigDecimal amount,
    String fromPhone
) {}
JAVA
package com.bank.service;

import com.bank.event.TransferCompletedEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class SafeTransferService {

    private final AccountRepository accountRepository;
    private final ApplicationEventPublisher eventPublisher;

    public SafeTransferService(AccountRepository accountRepository,
                                ApplicationEventPublisher eventPublisher) {
        this.accountRepository = accountRepository;
        this.eventPublisher = eventPublisher;
    }

    // CORRECT: publish event inside TX, listener fires after commit
    @Transactional
    public void transfer(String fromId, String toId, BigDecimal amount) {
        var from = accountRepository.findByIdForUpdate(fromId).orElseThrow();
        var to = accountRepository.findByIdForUpdate(toId).orElseThrow();

        if (from.getBalance().compareTo(amount) < 0) {
            throw new InsufficientFundsException(fromId, amount);
        }

        from.setBalance(from.getBalance().subtract(amount));
        to.setBalance(to.getBalance().add(amount));

        // Event published but NOT delivered yet
        eventPublisher.publishEvent(
            new TransferCompletedEvent(fromId, toId, amount, from.getPhone()));
    }
}
JAVA
package com.bank.listener;

import com.bank.event.TransferCompletedEvent;
import com.bank.service.SmsService;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionalEventListener;
import org.springframework.transaction.event.TransactionPhase;

@Component
public class TransferNotificationListener {

    private final SmsService smsService;

    public TransferNotificationListener(SmsService smsService) {
        this.smsService = smsService;
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onTransferCommitted(TransferCompletedEvent event) {
        // Fires ONLY after the transaction commits successfully
        smsService.send(event.fromPhone(),
            "Rs." + event.amount() + " transferred to " + event.toId());
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
    public void onTransferRolledBack(TransferCompletedEvent event) {
        // Fires ONLY if the transaction rolls back
        // No SMS sent to customer -- correct behavior
        // Optionally alert ops team
    }
}
TEXT
Success scenario:
  1. Debit SAV2001, credit CUR3001
  2. Event published (held in memory)
  3. COMMIT -> data is permanent
  4. AFTER_COMMIT listener fires -> SMS sent
  5. Rajesh receives SMS. Money actually moved. Correct.

Failure scenario:
  1. Debit SAV2001, credit CUR3001
  2. Event published (held in memory)
  3. Exception -> ROLLBACK -> data unchanged
  4. AFTER_ROLLBACK listener fires (optional)
  5. No SMS sent. Rajesh is not misled. Correct.

What other side effects are affected?

Every external action that cannot be undone by the transaction manager is a side effect that must be deferred. This includes:

  • Email -- sending a confirmation email inside the transaction.
  • SMS -- sending a notification via an SMS gateway.
  • HTTP/REST calls -- calling a partner API or webhook.
  • Kafka/RabbitMQ publish -- publishing a message to a broker.
  • Cache invalidation -- evicting a cache entry that is still valid if the TX rolls back.
  • File writes -- writing a file that should only exist after a successful commit.
JAVA
package com.bank.listener;

import com.bank.event.TransferCompletedEvent;
import org.springframework.cache.CacheManager;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionalEventListener;
import org.springframework.transaction.event.TransactionPhase;

@Component
public class PostCommitActionsListener {

    private final KafkaTemplate<String, String> kafkaTemplate;
    private final CacheManager cacheManager;

    public PostCommitActionsListener(KafkaTemplate<String, String> kafkaTemplate,
                                      CacheManager cacheManager) {
        this.kafkaTemplate = kafkaTemplate;
        this.cacheManager = cacheManager;
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void publishToKafkaAfterCommit(TransferCompletedEvent event) {
        // Kafka message published ONLY after DB commit
        kafkaTemplate.send("transfers",
            event.fromId() + "-" + event.toId(),
            "{\"amount\":" + event.amount() + "}");
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void invalidateCacheAfterCommit(TransferCompletedEvent event) {
        // Cache eviction ONLY after DB commit
        var cache = cacheManager.getCache("accounts");
        if (cache != null) {
            cache.evict(event.fromId());
            cache.evict(event.toId());
        }
    }
}
TEXT
Side effects that MUST be deferred to AFTER_COMMIT:
  - SMS notification
  - Email confirmation
  - Kafka/RabbitMQ event publish
  - Cache invalidation/eviction
  - Webhook callback to partner
  - File generation (statement PDF)
  - Push notification to mobile app

What about the Transactional Outbox for guaranteed delivery?

The @TransactionalEventListener fires in-memory. If the application process crashes right after the database commit but before the listener fires, the event is lost. For guaranteed delivery, use the Transactional Outbox pattern: write an outbox row in the same database transaction, and let a relay process publish it.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class OutboxTransferService {

    private final AccountRepository accountRepository;
    private final OutboxRepository outboxRepository;

    public OutboxTransferService(AccountRepository accountRepository,
                                  OutboxRepository outboxRepository) {
        this.accountRepository = accountRepository;
        this.outboxRepository = outboxRepository;
    }

    @Transactional
    public void transfer(String fromId, String toId, BigDecimal amount) {
        var from = accountRepository.findByIdForUpdate(fromId).orElseThrow();
        var to = accountRepository.findByIdForUpdate(toId).orElseThrow();

        from.setBalance(from.getBalance().subtract(amount));
        to.setBalance(to.getBalance().add(amount));

        // Outbox event in SAME transaction
        outboxRepository.save(new OutboxEvent(
            "TRANSFER_COMPLETED",
            "{\"from\":\"" + fromId + "\",\"to\":\""
                + toId + "\",\"amount\":" + amount + "}"
        ));
        // Both the transfer AND the outbox event commit atomically
        // If TX rolls back, the outbox event is also rolled back
    }
}
TEXT
@TransactionalEventListener (in-memory):
  + Simple to implement
  + No extra table needed
  - Lost if process crashes after commit but before listener fires
  - Suitable for non-critical side effects (cache invalidation)

Transactional Outbox (durable):
  + Survives process crashes
  + Guarantees at-least-once delivery
  + Relay can use Debezium CDC for real-time capture
  - Requires an outbox table and a relay process
  - Consumer must be idempotent (handle duplicates)
  - Suitable for critical side effects (SMS, partner calls)

What are common mistakes with side effects?

The most common mistake is putting the side effect call before the commit happens, either directly in the transactional method or in an @EventListener (which fires synchronously during the transaction, not after commit).

JAVA
package com.bank.listener;

import com.bank.event.TransferCompletedEvent;
import com.bank.service.SmsService;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionalEventListener;
import org.springframework.transaction.event.TransactionPhase;

@Component
public class MistakeVsCorrectListener {

    private final SmsService smsService;

    public MistakeVsCorrectListener(SmsService smsService) {
        this.smsService = smsService;
    }

    // WRONG: @EventListener fires DURING the transaction, not after commit
    @EventListener
    public void onTransferWrong(TransferCompletedEvent event) {
        // This fires synchronously INSIDE the transaction
        // If the TX rolls back after this, SMS was already sent
        smsService.send(event.fromPhone(),
            "Transfer completed: Rs." + event.amount());
    }

    // CORRECT: @TransactionalEventListener fires AFTER commit
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onTransferCorrect(TransferCompletedEvent event) {
        // This fires ONLY after the TX commits
        smsService.send(event.fromPhone(),
            "Transfer completed: Rs." + event.amount());
    }
}
TEXT
WRONG (@EventListener):
  1. Debit + credit in TX
  2. publishEvent()
  3. @EventListener fires SYNCHRONOUSLY -> SMS sent
  4. TX rolls back
  5. SMS already delivered. False notification.

CORRECT (@TransactionalEventListener AFTER_COMMIT):
  1. Debit + credit in TX
  2. publishEvent() -> event QUEUED
  3. TX commits
  4. @TransactionalEventListener fires -> SMS sent
  5. SMS matches the committed data. Correct.

What about holding a connection during an HTTP call?

A related but separate problem is that making an HTTP call inside a @Transactional method holds the database connection for the entire duration of the call. The database is idle during the network wait, but the pool slot is occupied. This is the connection waste problem discussed in the pseudo-saga anti-pattern.

Deferring the HTTP call to AFTER_COMMIT solves both problems: no false notification if the TX rolls back, AND no connection waste during the network wait.

TEXT
Two problems solved by AFTER_COMMIT:
  1. Correctness: side effect fires only after committed data
  2. Performance: connection released before slow external call

Inside TX (both problems):
  Connection held: 3015ms (10ms DB + 3000ms HTTP + 5ms commit)
  SMS sent before commit: false notification risk

AFTER_COMMIT (both solved):
  Connection held: 15ms (10ms DB + 5ms commit)
  SMS sent after commit: notification matches reality

What should a senior engineer know?

  • AFTER_COMMIT runs in a new transaction context -- the original transaction is already committed. If the listener needs to write to the database (for example, marking a notification as sent), it needs its own @Transactional annotation.
  • Exception in AFTER_COMMIT listener -- if the listener throws an exception, the original transaction is NOT rolled back (it was already committed). But the exception may propagate to the caller. Use try-catch in the listener.
  • Ordering multiple listeners -- use @Order to control the order of multiple AFTER_COMMIT listeners. Lower values execute first.
  • BEFORE_COMMIT phase -- fires before the commit, useful for last-minute validation. If the listener throws, the transaction rolls back.
JAVA
package com.bank.listener;

import com.bank.event.TransferCompletedEvent;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionalEventListener;
import org.springframework.transaction.event.TransactionPhase;

@Component
public class OrderedPostCommitListeners {

    @Order(1) // fires first
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    @Transactional // own TX for the audit write
    public void auditAfterCommit(TransferCompletedEvent event) {
        // Write audit record in its own transaction
    }

    @Order(2) // fires second
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void notifyAfterCommit(TransferCompletedEvent event) {
        try {
            // Send SMS -- if this fails, the transfer is still committed
            // smsService.send(...);
        } catch (Exception e) {
            // Log and continue. Do NOT let listener exception propagate.
        }
    }
}
TEXT
Listener execution order:
  1. TX commits (data permanent)
  2. @Order(1) auditAfterCommit -> writes audit in new TX
  3. @Order(2) notifyAfterCommit -> sends SMS

If notifyAfterCommit throws:
  - Transfer is still committed (cannot undo)
  - Audit record is still committed (separate TX)
  - Only the SMS failed -- retry via outbox if critical

What are common interview follow-up questions?

  • Does @TransactionalEventListener work without @Transactional on the publishing method? Yes, but the listener will not fire because there is no transaction to observe. The event is simply lost.
  • What if the listener throws an exception? The original transaction is already committed and cannot be rolled back. The exception may propagate to the caller. Always use try-catch in AFTER_COMMIT listeners.
  • How do you test AFTER_COMMIT listeners? Use @SpringBootTest with a test that verifies the side effect was triggered after the transactional method completes. Mock the SMS service and verify it was called.
  • Can you use CompletableFuture instead of @TransactionalEventListener? You can, but you lose the guaranteed timing. CompletableFuture runs asynchronously but does not wait for the commit. @TransactionalEventListener guarantees the event fires only after the transaction outcome is determined.

Visual Overview

DIAGRAM
┌──────────────────────────────────────────────────────────┐
│  WRONG: Side effect INSIDE transaction                    │
│                                                           │
│  @Transactional {                                         │
│    debit SAV2001                                          │
│    credit CUR3001                                         │
│    smsService.send("Transfer success")  ◄── SMS sent     │
│    ... exception or DB crash ...                          │
│    ROLLBACK                             ◄── DB undone    │
│  }                                                        │
│                                                           │
│  Result: SMS sent, but money did NOT move.                │
│  Customer misled.                                         │
└──────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────┐
│  CORRECT: Side effect AFTER_COMMIT                        │
│                                                           │
│  @Transactional {                                         │
│    debit SAV2001                                          │
│    credit CUR3001                                         │
│    publishEvent(TransferCompletedEvent)  ◄── event queued│
│  }                                                        │
│           │                                               │
│        COMMIT ─── data permanent                          │
│           │                                               │
│  @TransactionalEventListener(AFTER_COMMIT) {              │
│    smsService.send("Transfer success")   ◄── SMS sent    │
│  }                                                        │
│                                                           │
│  Result: SMS sent ONLY after data is permanent.           │
│  Customer correctly informed.                             │
└──────────────────────────────────────────────────────────┘

Key Takeaway

@Transactional only governs database resources. External side effects like sending an email, publishing to a REST API, calling an SMS gateway, or writing to a file are not undone when the transaction rolls back. If you send a confirmation SMS inside a @Transactional method and the database then rolls back, the customer receives a notification for a transfer that never happened. This is a correctness bug that destroys customer trust.

In Rajesh's IMPS transfer service at the National Bank of India, the fix is publishing a domain event inside the transaction and using @TransactionalEventListener with AFTER_COMMIT phase on the listener. The event is queued during the transaction but not delivered until the outcome is known. If the transaction commits, the AFTER_COMMIT listener fires and sends the SMS, which is safe because the data is permanent. If the transaction rolls back, no listener fires and no false notification goes out.

For guaranteed delivery across process restarts, Pankaj, the architect, recommends the Transactional Outbox pattern: write an outbox event in the same transaction as the business data, and let a relay process publish it asynchronously. This survives crashes, unlike in-memory events that are lost if the process dies right after commit.

Remember one sentence: fire external side effects only AFTER_COMMIT, never inside the transaction boundary, because the transaction manager cannot unsend an email.

---

Share
Use ← → arrow keys to navigate