Back to Events & Transaction Callbacks
Q3Question 3 of 3

What are TransactionSynchronization callbacks?

Key Concepts
  • TransactionSynchronization is a Spring callback interface that hooks custom logic into the lifecycle of the current transaction
  • Register an implementation with TransactionSynchronizationManager.registerSynchronization() while a transaction is active
  • beforeCommit runs just before commit and can abort the transaction by throwing an exception
  • afterCommit runs only after a successful commit and is the safe place for external side effects like SMS and Kafka
  • afterCompletion always runs at the end with a status (COMMITTED or ROLLED_BACK) for cleanup
  • An exception thrown in afterCommit does not roll back the transaction because the commit decision is already final

Definition

TransactionSynchronization is a Spring callback interface that lets you hook custom logic into the lifecycle of the current transaction, running the right side effect at the right moment: notify only after commit, clean up always after completion.
  • TransactionSynchronization -- an interface with callback methods (beforeCommit, afterCommit, beforeCompletion, afterCompletion) that Spring calls at well-defined points in the transaction lifecycle.
  • registerSynchronization -- a method on TransactionSynchronizationManager that registers a callback implementation while a transaction is active.
  • beforeCommit(boolean readOnly) -- runs just before the commit is issued; throwing here triggers a rollback.
  • afterCommit() -- runs only after a successful commit; the data is durable and it is safe to send external notifications.
  • afterCompletion(int status) -- runs after the transaction ends regardless of outcome, with STATUS_COMMITTED or STATUS_ROLLED_BACK.

Imagine Rajesh walks into the National Bank of India to transfer Rs.50,000 to Priya. The teller does not send Rajesh an SMS the moment he fills the form, because the form might still be rejected. The teller waits until the vault actually records the money movement and the register is stamped (afterCommit), and only then sends the SMS confirmation. If the form is rejected (afterCompletion with ROLLED_BACK), the teller sends a failure notification instead. TransactionSynchronization is this well-mannered teller: it runs the right action at the right moment.

Understanding the Concepts

What is TransactionSynchronization?

TransactionSynchronization is a Spring interface that lets you register callback methods that execute at specific points in the current transaction's lifecycle. Spring keeps a per-thread list of synchronizations bound to the active transaction. You add to that list by calling TransactionSynchronizationManager.registerSynchronization(...). This mechanism is the low-level foundation that @TransactionalEventListener is built on.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;

import java.math.BigDecimal;

@Service
public class TransferService {

    private final AccountRepository accountRepo;
    private final SmsGateway smsGateway;

    public TransferService(AccountRepository accountRepo,
                           SmsGateway smsGateway) {
        this.accountRepo = accountRepo;
        this.smsGateway = smsGateway;
    }

    @Transactional
    public void transfer(String fromId, String toId, BigDecimal amount) {
        accountRepo.debit(fromId, amount);
        accountRepo.credit(toId, amount);

        // Register a callback to send SMS only after commit
        TransactionSynchronizationManager.registerSynchronization(
            new TransactionSynchronization() {
                @Override
                public void afterCommit() {
                    smsGateway.send(fromId,
                        "Rs." + amount + " transferred to " + toId);
                }
            });
    }
}
TEXT
Execution order:
  1. debit() -- SQL UPDATE
  2. credit() -- SQL UPDATE
  3. registerSynchronization() -- callback stored in thread-local list
  4. COMMIT issued by Spring
  5. afterCommit() fires -- SMS sent to Rajesh

Why does TransactionSynchronization exist?

It exists because side effects that depend on the transaction outcome must not run until the outcome is known. Sending an SMS inside the transaction body means the SMS is sent before the commit happens. If the commit later fails, the customer receives a false confirmation. TransactionSynchronization solves this by providing precise callback hooks at every lifecycle point.

The callbacks cover four moments: before the commit is issued (last chance to validate), after a successful commit (safe for notifications), before the transaction ends regardless of outcome (resource release), and after the transaction ends regardless of outcome (cleanup and metrics).

JAVA
package com.bank.service;

import org.springframework.transaction.support.TransactionSynchronization;

public class TransferSynchronization implements TransactionSynchronization {

    private final String accountId;
    private final String message;
    private final SmsGateway smsGateway;
    private final AccountCache cache;

    public TransferSynchronization(String accountId, String message,
                                    SmsGateway smsGateway,
                                    AccountCache cache) {
        this.accountId = accountId;
        this.message = message;
        this.smsGateway = smsGateway;
        this.cache = cache;
    }

    @Override
    public void beforeCommit(boolean readOnly) {
        // Last chance to validate before commit
        System.out.println("About to commit transfer for " + accountId);
    }

    @Override
    public void afterCommit() {
        // Safe for external side effects -- data is durable
        smsGateway.send(accountId, message);
    }

    @Override
    public void beforeCompletion() {
        // Release resources before TX ends
        System.out.println("TX ending for " + accountId);
    }

    @Override
    public void afterCompletion(int status) {
        // Always runs -- clean up regardless of outcome
        cache.evict(accountId);
        if (status == STATUS_ROLLED_BACK) {
            System.out.println("Transfer rolled back for " + accountId);
        }
    }
}

How does beforeCommit work?

The beforeCommit(boolean readOnly) callback runs just before Spring tells the database to commit. It receives a boolean indicating whether the transaction is read-only. This is the last moment where you can still influence the transaction. If you throw an exception in beforeCommit, the transaction is rolled back.

This is the right place for a final validation, such as verifying that the ledger balances before the transfer is committed.

JAVA
package com.bank.sync;

import org.springframework.transaction.support.TransactionSynchronization;

public class LedgerValidationSync implements TransactionSynchronization {

    private final LedgerService ledgerService;

    public LedgerValidationSync(LedgerService ledgerService) {
        this.ledgerService = ledgerService;
    }

    @Override
    public void beforeCommit(boolean readOnly) {
        if (!readOnly && !ledgerService.isBalanced()) {
            throw new IllegalStateException(
                "Ledger not balanced -- aborting commit");
        }
    }
}
TEXT
Scenario 1: ledger is balanced
  beforeCommit() -- passes, no exception
  COMMIT proceeds normally

Scenario 2: ledger is NOT balanced
  beforeCommit() -- throws IllegalStateException
  COMMIT is cancelled, ROLLBACK is issued

How does afterCommit work?

The afterCommit() callback runs only if the transaction committed successfully. At this point the data is durable in the database. This is the safe place for external side effects: sending SMS, publishing to Kafka, calling a webhook, or triggering an email.

A critical nuance is that the original transaction is already finished by the time afterCommit runs. If you need to write to the database inside this callback, you need a new transaction.

JAVA
package com.bank.sync;

import org.springframework.transaction.support.TransactionSynchronization;

public class NotificationSync implements TransactionSynchronization {

    private final String accountId;
    private final SmsGateway smsGateway;

    public NotificationSync(String accountId, SmsGateway smsGateway) {
        this.accountId = accountId;
        this.smsGateway = smsGateway;
    }

    @Override
    public void afterCommit() {
        // Data is durable -- safe to notify
        smsGateway.send(accountId,
            "Your transfer has been completed successfully.");
    }
}

How does afterCompletion work?

The afterCompletion(int status) callback always runs at the very end of the transaction, regardless of whether it committed or rolled back. The status parameter tells you the outcome: STATUS_COMMITTED, STATUS_ROLLED_BACK, or STATUS_UNKNOWN.

This is the right place for cache eviction, metric updates, and resource cleanup that must happen no matter what.

JAVA
package com.bank.sync;

import org.springframework.transaction.support.TransactionSynchronization;

public class CacheCleanupSync implements TransactionSynchronization {

    private final String accountId;
    private final AccountCache cache;
    private final MetricsService metrics;

    public CacheCleanupSync(String accountId,
                             AccountCache cache,
                             MetricsService metrics) {
        this.accountId = accountId;
        this.cache = cache;
        this.metrics = metrics;
    }

    @Override
    public void afterCompletion(int status) {
        cache.evict(accountId);
        if (status == STATUS_COMMITTED) {
            metrics.incrementSuccessfulTransfers();
        } else if (status == STATUS_ROLLED_BACK) {
            metrics.incrementFailedTransfers();
        }
    }
}
TEXT
After successful commit:
  afterCompletion(STATUS_COMMITTED)
  cache evicted, success metric incremented

After rollback:
  afterCompletion(STATUS_ROLLED_BACK)
  cache evicted, failure metric incremented

How is TransactionSynchronization different from @TransactionalEventListener?

@TransactionalEventListener is a higher-level abstraction built on top of TransactionSynchronization. Both achieve the same goal, but they differ in how you register and use them.

| Aspect | TransactionSynchronization | @TransactionalEventListener |
|---|---|---|
| Registration | Programmatic: call registerSynchronization() | Declarative: annotate a method |
| Scope | One-off: registered within a specific method | Global: listens for all events of a type |
| Coupling | Tightly coupled to the service method | Loosely coupled via events |
| Reusability | Low: inline or dedicated class | High: any publisher can trigger it |
| Testability | Harder to unit test | Easier: mock the event |

JAVA
// TransactionSynchronization: programmatic, one-off
@Transactional
public void transfer(String fromId, String toId, BigDecimal amount) {
    accountRepo.debit(fromId, amount);
    accountRepo.credit(toId, amount);
    TransactionSynchronizationManager.registerSynchronization(
        new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                smsGateway.send(fromId, "Transfer done");
            }
        });
}

// @TransactionalEventListener: declarative, reusable
@Transactional
public void transfer(String fromId, String toId, BigDecimal amount) {
    accountRepo.debit(fromId, amount);
    accountRepo.credit(toId, amount);
    eventPublisher.publishEvent(new TransferEvent(fromId, toId, amount));
}

// Separate listener class
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onTransfer(TransferEvent event) {
    smsGateway.send(event.getFromAccountId(), "Transfer done");
}

What are common mistakes?

The first mistake is calling registerSynchronization() outside an active transaction. Spring throws an IllegalStateException if no transaction is active. Always guard the registration.

JAVA
package com.bank.service;

import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;

public class SafeRegistration {

    // WRONG: no guard, crashes if called outside TX
    public void registerWrong() {
        TransactionSynchronizationManager.registerSynchronization(
            new TransactionSynchronization() {
                @Override
                public void afterCommit() {
                    System.out.println("Committed");
                }
            });
    }

    // CORRECT: guard with isSynchronizationActive()
    public void registerCorrect() {
        if (TransactionSynchronizationManager.isSynchronizationActive()) {
            TransactionSynchronizationManager.registerSynchronization(
                new TransactionSynchronization() {
                    @Override
                    public void afterCommit() {
                        System.out.println("Committed");
                    }
                });
        } else {
            // Fallback: run immediately or log a warning
            System.out.println("No active TX, running immediately");
        }
    }
}

The second mistake is assuming that an exception in afterCommit rolls back the transaction. By the time afterCommit runs, the commit decision is already final. An exception there is only logged, and the transaction remains committed.

JAVA
// WRONG assumption: afterCommit exception rolls back TX
@Override
public void afterCommit() {
    // If this throws, the TX is NOT rolled back
    externalApi.callPartner(); // throws RuntimeException
}
// Result: money moved, but partner call failed
// You must handle your own errors here

What should a senior engineer know?

A senior engineer knows that TransactionSynchronization callbacks run on the same thread that committed the transaction. A slow SMS call in afterCommit blocks the request thread. For production systems, delegate to an async executor or use @TransactionalEventListener with @Async.

Senior engineers also know about TransactionSynchronizationUtils.triggerAfterCommit() for testing, and that TransactionSynchronizationManager stores resources in ThreadLocal, which means synchronizations do not work across threads or in reactive applications without special handling.

JAVA
package com.bank.service;

import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class AsyncAfterCommit {

    private static final ExecutorService EXECUTOR =
        Executors.newFixedThreadPool(4);

    public static void registerAsyncAfterCommit(Runnable task) {
        TransactionSynchronizationManager.registerSynchronization(
            new TransactionSynchronization() {
                @Override
                public void afterCommit() {
                    EXECUTOR.submit(task);  // non-blocking
                }
            });
    }
}

What are common interview follow-up questions?

  • What is the difference between beforeCommit and beforeCompletion?
  • Can you register multiple synchronizations in the same transaction? In what order do they run?
  • What happens if beforeCommit throws an exception?
  • How does @TransactionalEventListener use TransactionSynchronization internally?
  • How do you test a TransactionSynchronization callback in a unit test?

Visual Overview

DIAGRAM
  Transaction Lifecycle with Synchronization Callbacks

  BEGIN ──▶ debit() ──▶ credit() ──▶ registerSynchronization()
    │
    ▼
  ┌──────────────────────┐
  │  beforeCommit()      │  ◀── last chance to validate
  │  (can abort TX)      │
  └──────────┬───────────┘
             │
  ┌──────────▼───────────┐
  │  beforeCompletion()  │  ◀── release resources
  └──────────┬───────────┘
             │
       ┌─────┴─────┐
       │  COMMIT   │
       └─────┬─────┘
             │
  ┌──────────▼───────────┐
  │  afterCommit()       │  ◀── send SMS (data is durable)
  └──────────┬───────────┘
             │
  ┌──────────▼───────────┐
  │  afterCompletion()   │  ◀── cache evict, metrics
  │  status=COMMITTED    │
  └──────────────────────┘

  If ROLLBACK instead of COMMIT:
    beforeCommit()     -- does NOT run
    beforeCompletion() -- RUNS
    afterCommit()      -- does NOT run
    afterCompletion()  -- RUNS (status=ROLLED_BACK)

Key Takeaway

TransactionSynchronization is Spring's callback interface for hooking custom logic into the lifecycle of the current transaction. You register an implementation using TransactionSynchronizationManager.registerSynchronization while a transaction is active, and Spring calls your callbacks at precise moments. The beforeCommit method is your last chance to enforce an invariant or abort the transaction. The afterCommit method fires only when the money has truly moved, making it the safe place for SMS and Kafka messages. The afterCompletion method always runs with a status telling you the outcome, making it the right place for cache eviction and metrics.

In the National Bank of India transfer system, Pankaj the architect moved all external notifications into afterCommit callbacks. The compliance audit row was written in a beforeCommit listener so it committed atomically with the money movement. The cache eviction went in afterCompletion so stale account balances were always cleared regardless of outcome. This design eliminated false confirmations and ensured cleanup always happened.

The critical thing to remember is that registration only works inside an active transaction, so always guard it with isSynchronizationActive(). An exception thrown inside afterCommit does not roll back the transaction because the commit decision is already final. In modern code, prefer the higher-level @TransactionalEventListener annotation, which is built on this exact mechanism.

Remember one sentence: afterCommit is for side effects the customer will trust, afterCompletion is for cleanup that must always happen.

---

Share
Use ← → arrow keys to navigate