Skip to main content
spring internals

Transaction Lifecycle Callbacks and Post-Commit Actions

8 min read Chapter 57 of 78

Transaction Lifecycle Callbacks and Post-Commit Actions

The transaction committed. The order is in the database. Now you need to send a notification, update a search index, or trigger a downstream workflow. These actions must only happen if the transaction succeeded. If you fire them before the commit and the transaction rolls back, you have sent a notification for an order that does not exist.

TransactionSynchronization callbacks solve this. They let you register code that runs at specific points in the transaction lifecycle: before commit, after commit, and after completion (success or failure).

The TransactionSynchronization Interface

public interface TransactionSynchronization extends Ordered, Flushable {

    int STATUS_COMMITTED = 0;
    int STATUS_ROLLED_BACK = 1;
    int STATUS_UNKNOWN = 2;

    default int getOrder() { return Ordered.LOWEST_PRECEDENCE; }
    default void suspend() {}
    default void resume() {}
    default void flush() {}
    default void beforeCommit(boolean readOnly) {}
    default void beforeCompletion() {}
    default void afterCommit() {}
    default void afterCompletion(int status) {}
}

The callbacks fire in this order during a successful transaction:

@Transactional method executes
  -> business logic runs
  -> method returns normally

Transaction manager begins commit sequence:
  1. beforeCommit(readOnly=false)    // Last chance to veto
  2. beforeCompletion()              // Pre-commit cleanup
  3. DATABASE COMMIT                 // Actual commit on the Connection
  4. afterCommit()                   // Commit confirmed by database
  5. afterCompletion(STATUS_COMMITTED) // Final cleanup

During a rollback:

@Transactional method throws exception
  -> exception propagates to transaction proxy

Transaction manager begins rollback sequence:
  1. beforeCompletion()                 // Pre-rollback cleanup
  2. DATABASE ROLLBACK                  // Actual rollback on the Connection
  3. afterCompletion(STATUS_ROLLED_BACK) // Final cleanup
     // NOTE: afterCommit() does NOT fire

The key guarantee: afterCommit() fires only after the database has confirmed the commit. Not before. Not during. After.

Registering Synchronizations

Register a synchronization from any code running inside a @Transactional context:

TransactionSynchronizationManager.registerSynchronization(
    new TransactionSynchronization() {
        @Override
        public void afterCommit() {
            // Safe to perform external actions here
        }
    }
);

The registered synchronization lives for the duration of the current transaction. It is discarded after the transaction completes. If you need callbacks for the next transaction, register again.

Registration requires an active transaction synchronization context. If you call registerSynchronization() outside a @Transactional method:

java.lang.IllegalStateException:
Transaction synchronization is not active

You can guard against this:

if (TransactionSynchronizationManager.isSynchronizationActive()) {
    TransactionSynchronizationManager.registerSynchronization(...);
} else {
    // No transaction: execute immediately or skip
}

Post-Commit Notifications in the SaaS Backend

The SaaS backend sends notifications after orders are created. The notification must not fire if the order creation rolls back.

@Service
public class OrderService {
    private final OrderRepository orderRepository;
    private final NotificationService notificationService;
    private final SearchIndexService searchIndexService;

    @Transactional
    public Order createOrder(CreateOrderRequest request) {
        Order order = new Order();
        order.setTenantId(request.tenantId());
        order.setCustomerId(request.customerId());
        order.setStatus(OrderStatus.PENDING);
        order.setTotal(calculateTotal(request.lineItems()));

        Order saved = orderRepository.save(order);

        // Register post-commit actions
        TransactionSynchronizationManager.registerSynchronization(
            new TransactionSynchronization() {
                @Override
                public void afterCommit() {
                    notificationService.sendOrderCreated(
                        saved.getTenantId(), saved.getId()
                    );
                    searchIndexService.indexOrder(saved);
                }
            }
        );

        return saved;
    }
}

The sequence:

1. orderRepository.save(order)          -> EntityManager.persist()
2. registerSynchronization(...)         -> callback stored in thread-local
3. createOrder() returns normally
4. Transaction proxy calls commit
5. EntityManager.flush()                -> INSERT INTO orders ...
6. Connection.commit()                  -> Database confirms
7. afterCommit() fires:
   a. notificationService.sendOrderCreated()
   b. searchIndexService.indexOrder()

If step 5 fails (constraint violation, for example), the transaction rolls back. Steps 6 and 7 never execute. No phantom notification.

Multiple Synchronizations and Ordering

You can register multiple synchronizations. They fire in order determined by getOrder():

TransactionSynchronizationManager.registerSynchronization(
    new TransactionSynchronization() {
        @Override
        public int getOrder() { return 1; }

        @Override
        public void afterCommit() {
            // Fires first: update search index
            searchIndexService.indexOrder(saved);
        }
    }
);

TransactionSynchronizationManager.registerSynchronization(
    new TransactionSynchronization() {
        @Override
        public int getOrder() { return 2; }

        @Override
        public void afterCommit() {
            // Fires second: send notification (may reference search index)
            notificationService.sendOrderCreated(saved.getTenantId(), saved.getId());
        }
    }
);

Lower order values execute first. Default is Ordered.LOWEST_PRECEDENCE (Integer.MAX_VALUE), so unordered synchronizations run last.

afterCompletion: Handling Both Outcomes

afterCompletion(int status) runs regardless of commit or rollback. The status argument tells you which:

TransactionSynchronizationManager.registerSynchronization(
    new TransactionSynchronization() {
        @Override
        public void afterCompletion(int status) {
            switch (status) {
                case STATUS_COMMITTED -> {
                    metricsService.recordOrderCreated(order.getTenantId());
                    log.info("Order {} committed", order.getId());
                }
                case STATUS_ROLLED_BACK -> {
                    metricsService.recordOrderFailed(order.getTenantId());
                    log.warn("Order {} rolled back", order.getId());
                }
                case STATUS_UNKNOWN -> {
                    // Connection lost during commit. Outcome unknown.
                    log.error("Order {} outcome unknown", order.getId());
                }
            }
        }
    }
);

STATUS_UNKNOWN is rare but real. It occurs when the connection drops during the commit. The database may have committed, but the application cannot confirm. Your application needs a reconciliation strategy for this case.

beforeCommit: Last-Chance Validation

beforeCommit() runs after the method completes but before the database commit. Throwing from beforeCommit() triggers a rollback:

TransactionSynchronizationManager.registerSynchronization(
    new TransactionSynchronization() {
        @Override
        public void beforeCommit(boolean readOnly) {
            if (!readOnly) {
                // Validate business invariants one final time
                long orderCount = orderRepository.countByTenantId(tenantId);
                if (orderCount > tenant.getMaxOrders()) {
                    throw new TenantQuotaExceededException(tenantId);
                }
            }
        }
    }
);

Use beforeCommit sparingly. Most validation belongs in the service method itself. beforeCommit is for cross-cutting invariants that span multiple repository calls and must be checked right before the commit point.

Extracting a Reusable Pattern

Registering anonymous TransactionSynchronization implementations inline is verbose. Extract a utility:

public final class AfterCommit {

    private AfterCommit() {}

    public static void execute(Runnable action) {
        if (!TransactionSynchronizationManager.isSynchronizationActive()) {
            throw new IllegalStateException(
                "Cannot register afterCommit callback: no active transaction"
            );
        }
        TransactionSynchronizationManager.registerSynchronization(
            new TransactionSynchronization() {
                @Override
                public void afterCommit() {
                    action.run();
                }
            }
        );
    }
}

Usage becomes concise:

@Transactional
public Order createOrder(CreateOrderRequest request) {
    Order saved = orderRepository.save(buildOrder(request));

    AfterCommit.execute(() -> notificationService.sendOrderCreated(
        saved.getTenantId(), saved.getId()
    ));

    return saved;
}

The Failure Mode: Pre-Commit Side Effects

// BROKEN: notification fires before transaction commits
@Service
public class OrderService {
    private final OrderRepository orderRepository;
    private final NotificationService notificationService;

    @Transactional
    public Order createOrder(CreateOrderRequest request) {
        Order order = buildOrder(request);
        Order saved = orderRepository.save(order);

        // This fires NOW, not after commit
        notificationService.sendOrderCreated(
            saved.getTenantId(), saved.getId()
        );

        // If this throws, the transaction rolls back
        // but the notification was already sent
        auditRepository.save(new AuditEntry(saved.getId(), "CREATED"));

        return saved;
    }
}

The problem: notificationService.sendOrderCreated() executes inside the transaction, before the commit. If auditRepository.save() fails and the transaction rolls back, the order does not exist in the database, but the notification was already delivered. The customer received a confirmation for a non-existent order.

The same problem occurs with any external side effect: HTTP calls to payment providers, messages published to a queue, emails sent through an SMTP gateway. All of these are not transactional with the database. Once sent, they cannot be rolled back.

The Correct Pattern

// CORRECT: side effects only after commit confirmation
@Service
public class OrderService {
    private final OrderRepository orderRepository;
    private final AuditRepository auditRepository;
    private final NotificationService notificationService;

    @Transactional
    public Order createOrder(CreateOrderRequest request) {
        Order order = buildOrder(request);
        Order saved = orderRepository.save(order);
        auditRepository.save(new AuditEntry(saved.getId(), "CREATED"));

        // Register callback, do not execute yet
        AfterCommit.execute(() -> notificationService.sendOrderCreated(
            saved.getTenantId(), saved.getId()
        ));

        return saved;
        // Transaction commits here
        // THEN afterCommit fires
        // THEN notification sends
    }
}

The afterCommit callback guarantees ordering:

  1. orderRepository.save() executes (EntityManager.persist)
  2. auditRepository.save() executes (EntityManager.persist)
  3. Transaction manager flushes the EntityManager (INSERT statements)
  4. Database commits
  5. afterCommit() fires
  6. notificationService.sendOrderCreated() executes

If step 3 fails (unique constraint, foreign key violation), the transaction rolls back. Steps 4 through 6 never happen. No phantom notification.

If step 6 fails (notification service down), the order and audit entry are already committed. You lose the notification but keep data consistency. Handle notification failures with a retry mechanism or an outbox pattern, not by rolling back the order.

The rule: database writes go inside the transaction. External side effects go in afterCommit. The database is your source of truth. External systems catch up.

Caveats

afterCommit runs on the same thread, blocking the response. If notificationService.sendOrderCreated() takes 2 seconds, the HTTP response is delayed by 2 seconds. For latency-sensitive endpoints, dispatch the callback work to an async executor:

AfterCommit.execute(() ->
    CompletableFuture.runAsync(() ->
        notificationService.sendOrderCreated(saved.getTenantId(), saved.getId()),
        notificationExecutor
    )
);

The EntityManager is closed during afterCommit. You cannot load lazy associations or execute queries inside afterCommit. The transaction is done. The EntityManager is unbound. If you need entity data in the callback, capture it before registering:

UUID orderId = saved.getId();
UUID tenantId = saved.getTenantId();
String customerEmail = saved.getCustomer().getEmail();  // Load eagerly

AfterCommit.execute(() ->
    notificationService.sendOrderCreated(tenantId, orderId, customerEmail)
);

afterCommit is not itself transactional. If you need to write to the database inside afterCommit, you need a new transaction. Use a @Transactional(propagation = REQUIRES_NEW) method on a separate bean (CH10). But question whether you truly need this. If you do, the outbox pattern is usually the better solution.