Executing Asynchronous Operations After Spring Transaction Commit: Principles, Pitfalls, and Best Practices

The article explains why sending messages before a Spring transaction commits can cause data inconsistency, and demonstrates how to reliably execute asynchronous actions such as MQ notifications after a successful commit using TransactionSynchronization, custom collectors, and @TransactionalEventListener, while highlighting common pitfalls and mitigation strategies.

The Dominant Programmer
The Dominant Programmer
The Dominant Programmer
Executing Asynchronous Operations After Spring Transaction Commit: Principles, Pitfalls, and Best Practices

Why Execute After Transaction Commit

Typical error scenario

@Transactional
public void createOrder(OrderDto orderDto) {
    // 1. Save order to DB
    Order order = new Order();
    order.setOrderCode("ORD001");
    order.setStatus(0);
    orderRepository.save(order);
    // 2. Send MQ notification
    mqSender.send(order.getId());
    // 3. Additional DB operation...
    orderDetailRepository.save(detail); // assume exception here
}

Problem: If step 3 throws an exception, the transaction rolls back, so the order is not persisted, but the MQ message has already been sent, causing downstream errors.

Root cause: MQ sending is non‑rollbackable; its effect occurs before the database commit.

Correct approach: let the transaction commit first, then send MQ.

Spring Transaction Basics

What is a transaction?

A transaction groups database operations so that they either all commit or all roll back.

@Transactional annotation

@Transactional(
    propagation = Propagation.REQUIRED,
    rollbackFor = Exception.class
)
public void businessMethod() {
    // all DB operations inside the same transaction
}

Transaction lifecycle

Method entry
    │
    ▼
Spring begins transaction (BEGIN)
    │
    ▼
Execute method body (DB ops)
    │
    ├── Normal end → COMMIT → data visible
    └── Exception → ROLLBACK → changes undone

Key point: Data becomes visible to other threads only after COMMIT.

Implementations for “after‑commit” actions

1. TransactionSynchronization (native Spring)

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

@Transactional
public void createOrderAndNotify(OrderDto orderDto) {
    // 1. DB operations
    Order order = new Order();
    order.setOrderCode("ORD001");
    order.setStatus(0);
    orderRepository.save(order);
    // 2. Register after‑commit callback
    TransactionSynchronizationManager.registerSynchronization(
        new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                // executed only after successful commit
                mqSender.send(order.getId());
            }
        }
    );
    // 3. More DB ops – if exception occurs, MQ is not sent
    orderDetailRepository.save(detail);
}

2. TransactionSynchronizationAdapter (simplified)

import org.springframework.transaction.support.TransactionSynchronizationAdapter;

@Transactional
public void createOrderAndNotify(OrderDto orderDto) {
    Order order = orderRepository.save(buildOrder(orderDto));
    TransactionSynchronizationManager.registerSynchronization(
        new TransactionSynchronizationAdapter() {
            @Override
            public void afterCommit() {
                mqSender.send(order.getId());
            }
        }
    );
}
Note: TransactionSynchronizationAdapter is deprecated since Spring 5.3; implement TransactionSynchronization directly.

3. Custom collector utility

/**
 * Collector for actions to run after transaction commit.
 * Collects multiple actions and triggers them in afterCommit().
 */
public class AfterTransactionActionCollector implements TransactionSynchronization {
    private final List<Runnable> commitActions = new ArrayList<>();
    private final List<Runnable> rollbackActions = new ArrayList<>();

    public void addCommitSyncAction(Runnable action) {
        commitActions.add(action);
    }
    public void addRollbackSyncAction(Runnable action) {
        rollbackActions.add(action);
    }
    @Override
    public void afterCommit() {
        for (Runnable action : commitActions) {
            try { action.run(); } catch (Exception e) {
                log.warn("Exception in after‑commit action", e);
            }
        }
    }
    @Override
    public void afterCompletion(int status) {
        if (status == STATUS_ROLLED_BACK) {
            for (Runnable action : rollbackActions) {
                try { action.run(); } catch (Exception e) {
                    log.warn("Exception in after‑rollback action", e);
                }
            }
        }
    }
}

Usage example:

@Transactional
public void processOrder(OrderDto orderDto) {
    Order order = orderRepository.save(buildOrder(orderDto));
    AfterTransactionActionCollector collector = new AfterTransactionActionCollector();
    collector.addCommitSyncAction(() -> mqSender.send(order.getId()));
    collector.addCommitSyncAction(() -> smsSender.sendOrderConfirmSms(order.getPhone()));
    collector.addRollbackSyncAction(() -> stockLockService.releaseLock(order.getSkuId()));
    TransactionSynchronizationManager.registerSynchronization(collector);
}

4. @TransactionalEventListener (Spring 4.2+)

// 1. Define event
public class OrderCreatedEvent {
    private final Integer orderId;
    public OrderCreatedEvent(Integer orderId) { this.orderId = orderId; }
    public Integer getOrderId() { return orderId; }
}

// 2. Publish event inside transactional method
@Service
public class OrderServiceImpl implements OrderService {
    @Resource
    private ApplicationEventPublisher eventPublisher;

    @Transactional
    public void createOrder(OrderDto orderDto) {
        Order order = orderRepository.save(buildOrder(orderDto));
        eventPublisher.publishEvent(new OrderCreatedEvent(order.getId()));
    }
}

// 3. Listener that runs after commit
@Component
public class OrderEventListener {
    @Resource
    private MqSender mqSender;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleOrderCreated(OrderCreatedEvent event) {
        mqSender.send(event.getOrderId());
    }
}

TransactionSynchronization callback timing

The interface provides several callbacks:

public interface TransactionSynchronization {
    default void beforeCommit(boolean readOnly) {}
    default void beforeCompletion() {}
    default void afterCommit() {}
    default void afterCompletion(int status) {} // STATUS_COMMITTED or STATUS_ROLLED_BACK
}

Execution order:

Transaction start
    ▼
Business code
    ▼
beforeCommit()   ← last chance before commit
    ▼
beforeCompletion()
    ▼
COMMIT (or ROLLBACK)
    ▼
if commit succeeded → afterCommit()
    ▼
afterCompletion(0)   ← cleanup
else → afterCompletion(1)   ← rollback cleanup

Common pitfalls and cautions

5.1 Exceptions in afterCommit do not roll back

TransactionSynchronizationManager.registerSynchronization(
    new TransactionSynchronization() {
        @Override
        public void afterCommit() {
            // Even if this throws, the transaction is already committed
            mqSender.send(orderId); // MQ failure will not trigger rollback
        }
    }
);

Solution: wrap the call in try‑catch, log the error, and handle retry via compensation.

5.2 Registration must happen inside an active transaction

// Wrong: no @Transactional, registration fails
public void noTransactionMethod() {
    // No active transaction, registration is ineffective
    TransactionSynchronizationManager.registerSynchronization(...);
}

Check with TransactionSynchronizationManager.isSynchronizationActive() before registering.

5.3 Do not perform DB writes in afterCommit without a new transaction

@Override
public void afterCommit() {
    // Dangerous: this runs outside the original transaction
    orderRepository.updateStatus(orderId, "NOTIFIED");
}

Correct practice: invoke a method annotated with @Transactional(propagation = Propagation.REQUIRES_NEW) for such writes.

5.4 Lambda captured variables must be effectively final

@Transactional
public void processOrder(OrderDto orderDto) {
    Order order = orderRepository.save(buildOrder(orderDto));
    Integer orderId = order.getId(); // capture into final variable
    AfterTransactionActionCollector collector = new AfterTransactionActionCollector();
    collector.addCommitSyncAction(() -> mqSender.send(orderId));
    TransactionSynchronizationManager.registerSynchronization(collector);
}

5.5 Nested transaction behavior

@Transactional
public void outerMethod() {
    TransactionSynchronizationManager.registerSynchronization(...);
    innerMethod(); // inherits the same transaction
}
@Transactional
public void innerMethod() {
    TransactionSynchronizationManager.registerSynchronization(...);
}

Callbacks are bound to the outermost transaction; they fire only when the outer transaction commits.

Comparison of the four approaches

TransactionSynchronization : works in all scenarios, flexible, but code can be verbose.

AfterTransactionActionCollector : convenient when multiple actions need to be registered, requires a custom utility class.

@TransactionalEventListener : clean, decoupled event‑driven style, but needs an event class.

Manual commit‑after execution : gives full control, unsuitable for declarative transactions.

Full example: order creation with multiple downstream notifications

@Service
public class OrderServiceImpl implements OrderService {
    @Resource private OrderRepository orderRepository;
    @Resource private OrderDetailRepository orderDetailRepository;
    @Resource private OrderMqSender orderMqSender;
    @Resource private SmsSender smsSender;

    /**
     * Create order and notify downstream systems.
     * DB ops are inside the transaction; MQ and SMS are sent after commit.
     */
    @Transactional(rollbackFor = Exception.class)
    public Integer createOrder(CreateOrderParamsDto paramsDto) {
        // ----- DB operations -----
        Order order = new Order();
        order.setOrderCode(generateOrderCode());
        order.setCustomerPhone(paramsDto.getPhone());
        order.setStatus(0);
        order.setAmount(paramsDto.getTotalAmount());
        orderRepository.saveAndFlush(order);
        for (OrderItemDto item : paramsDto.getItems()) {
            OrderDetail detail = new OrderDetail();
            detail.setOrderId(order.getId());
            detail.setSkuId(item.getSkuId());
            detail.setQuantity(item.getQuantity());
            orderDetailRepository.save(detail);
        }

        // ----- Register after‑commit actions -----
        Integer orderId = order.getId();
        String phone = order.getCustomerPhone();
        AfterTransactionActionCollector collector = new AfterTransactionActionCollector();

        collector.addCommitSyncAction(() -> {
            try { orderMqSender.sendOrderCreatedMq(orderId); }
            catch (Exception e) { log.warn("Order MQ send failed, orderId: {}", orderId, e); }
        });
        collector.addCommitSyncAction(() -> {
            try { smsSender.sendOrderConfirmSms(phone, orderId); }
            catch (Exception e) { log.warn("Order SMS send failed, phone: {}", phone, e); }
        });

        TransactionSynchronizationManager.registerSynchronization(collector);
        return orderId;
    }

    private String generateOrderCode() {
        return "ORD" + System.currentTimeMillis();
    }
}

Execution flow

1. Spring opens transaction
2. Save order main table → not visible yet
3. Save order details → not visible yet
4. Register afterCommit callbacks (no execution)
5. Method returns normally
6. Spring commits → COMMIT → data becomes visible
7. afterCommit triggers → MQ sent, SMS sent
   → downstream systems can query the order successfully

What if step 3 throws an exception?

1. Spring opens transaction
2. Save order main table → not visible
3. Save order detail → exception!
4. Spring rolls back → ROLLBACK → order data undone
5. afterCommit is not triggered → no MQ, no SMS
   → data consistency is preserved

Summary

Use the pattern when a transaction creates data that must be notified to external systems, and the notification cannot be rolled back.

Core principle: leverage Spring’s transaction synchronization to run side‑effects only after a successful COMMIT.

If afterCommit fails, log the error and retry later via a compensation task.

Compared with sending MQ at the end of the method, after‑commit execution guarantees that the database state is already visible, avoiding mismatched data.

Performance impact is negligible; only a callback object is registered.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaTransactionSpringasynchronousMQeventlistener
The Dominant Programmer
Written by

The Dominant Programmer

Resources and tutorials for programmers' advanced learning journey. Advanced tracks in Java, Python, and C#. Blog: https://blog.csdn.net/badao_liumang_qizhi

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.