skip to content

After-Commit Side Effects

Sending an email or publishing a message only after the transaction commits avoids acting on data that later disappears, and points straight at the transactional outbox for the remaining dual-write gap. A classic distributed-systems question dressed as a Spring one.

part ofSpring Frameworkoverview, primer and where to startread it →
on this pageshow

questions

5

Why would you delay sending an email or publishing a message until after the database transaction commits, instead of doing it inline in your service method?

level: juniorimportance: must knowfreq 70%

answer

  1. rollback can't un-send an email
  2. side effects aren't transactional
  3. wait until data is durable
  4. AFTER_COMMIT = truthful notification
  5. crash-after-commit still loses it

basics

~20 s

Because the transaction might still roll back. If you email or publish before commit and the transaction then fails, you have acted on data that was never saved. Waiting until after commit ensures the side effect only happens for data that actually persisted.

solid answer

~40 s

A database transaction can still roll back after your code runs — a later step throws, a constraint fails, or commit itself errors. If you send an email or publish a Kafka message inline, that side effect already left the building and cannot be undone: you have notified about an order that does not exist. External side effects are not transactional, so you defer them to AFTER_COMMIT — the point where the data is guaranteed durable. In Spring you express this with an event plus @TransactionalEventListener(phase = AFTER_COMMIT), or by registering a TransactionSynchronization. The trade-off: if the app crashes after commit but before the side effect runs, the notification is lost — that residual gap is what the transactional outbox pattern later addresses.

code

java · 21 lines
java
// BAD: side effect fires before the transaction is guaranteed to commit
@Transactional
public void placeOrder(Order order) {
    orderRepository.save(order);
    emailService.sendConfirmation(order); // if a later step rolls back,
                                          // the email is already gone
    inventoryService.reserve(order);      // <-- may throw -> rollback
}

// GOOD: publish an event; deliver the email only AFTER_COMMIT
@Transactional
public void placeOrder(Order order) {
    orderRepository.save(order);
    inventoryService.reserve(order);
    events.publishEvent(new OrderPlaced(order.getId()));
}

@TransactionalEventListener // phase defaults to AFTER_COMMIT
public void onOrderPlaced(OrderPlaced e) {
    emailService.sendConfirmation(e.orderId());
}

go deeper

for a junior

Must grasp the core idea: a rollback cannot un-send an email, so wait until the data is committed.

for a middle

Should name the concrete Spring mechanism (@TransactionalEventListener AFTER_COMMIT) rather than describing it vaguely.

for a senior

Should articulate the residual crash-after-commit gap and gesture at the outbox pattern.

for a principal

Frames this as one instance of the dual-write consistency problem and knows the boundary of what AFTER_COMMIT can and cannot guarantee.

## The core problem A relational transaction gives you atomicity **for the database only**. Everything you do to the outside world — sending an email, publishing to Kafka/RabbitMQ, calling a REST API, writing a file — is **not** part of that transaction and **cannot be rolled back**. Consider a naive service: 1. `orderRepository.save(order)` 2. `emailService.sendConfirmation(order)` ← side effect fires here 3. ...more work that throws an exception Spring's declarative transaction (`@Transactional`) will now roll back step 1 — the order is **not** in the database — but the confirmation email in step 2 has **already been sent**. The customer got a receipt for an order that does not exist. This is a classic **premature side effect** bug. ## The fix: run side effects AFTER_COMMIT You move the side effect to a hook that Spring fires **only after the transaction has successfully committed**. At that moment the data is durably persisted, so any notification about it is truthful. Two idiomatic Spring mechanisms: 1. **`@TransactionalEventListener`** — publish a domain event during the transaction; a listener annotated `@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)` (AFTER_COMMIT is the default phase) runs the side effect after commit. This is the clean, decoupled approach. 2. **`TransactionSynchronizationManager.registerSynchronization(...)`** — a lower-level callback API where you override `afterCommit()`. ## What 'after commit' guarantees — and does not - **Guarantees:** the transaction reached the durable state. Nothing your side effect sees will later vanish through rollback. - **Does NOT guarantee delivery:** if the JVM crashes, or the broker is down, in the window *between* commit and the side effect completing, the side effect is simply lost. Commit happened; the email did not. Spring does not retry it for you. This narrower failure mode is the **dual-write problem**, solved separately by the **transactional outbox** pattern (persist the intent to send inside the same transaction, deliver it asynchronously from that table). ## When to use Use AFTER_COMMIT for **any non-transactional side effect that must reflect committed data**: notifications, message/event publication, cache invalidation of external caches, triggering downstream workflows. Do **not** use it for work that must be atomic with the data — that belongs inside the transaction. ## Junior-friendly rule of thumb > Anything you cannot un-send should wait until the data is safely saved.

  • If the transaction rolls back, does the AFTER_COMMIT listener still run?
    No. AFTER_COMMIT listeners only fire on a successful commit. On rollback they are skipped entirely (an AFTER_ROLLBACK phase exists separately if you need that).
  • Is the problem fully solved once you move the email to AFTER_COMMIT?
    Mostly, but not completely. You have eliminated false notifications on rollback. A residual gap remains: if the app crashes between commit and sending, the email is lost — that's the dual-write problem the outbox pattern addresses.

saying these in an interview costs you the question

  • Claiming the email can be 'rolled back' along with the database transaction — external side effects are not transactional.
  • Believing AFTER_COMMIT guarantees the side effect will definitely be delivered (it doesn't survive a crash after commit).
  • Thinking the listener runs even on rollback.

context

open as a page

Explain how @TransactionalEventListener with TransactionPhase.AFTER_COMMIT works, and the gotchas around it (default phase, no active transaction, doing DB writes in the listener).

level: middleimportance: must knowfreq 65%

basics

~20 s

You publish an event inside the transaction; a method annotated @TransactionalEventListener runs it after the transaction commits (AFTER_COMMIT is the default phase). Gotchas: if there is no active transaction the event is dropped unless fallbackExecution=true, and DB writes in the listener need a new transaction.

open as a page

You moved message publishing to AFTER_COMMIT so you never publish on rollback. A colleague says the system is now fully consistent. What failure can still occur, and what is this problem called?

level: seniorimportance: must knowfreq 55%

basics

~20 s

The DB can commit and then the app crash (or the broker be down) before the message is published, so the event is lost forever. AFTER_COMMIT prevents false events but not lost ones. This is the dual-write problem: two systems updated without a shared transaction.

open as a page

Besides @TransactionalEventListener, how can you hook a callback to run after the current transaction commits using the lower-level synchronization API? When would you reach for it?

level: middleimportance: should knowfreq 40%

basics

~10 s

Call TransactionSynchronizationManager.registerSynchronization(...) with a TransactionSynchronization whose afterCommit() method holds your side effect. Spring runs it after the current transaction commits. Use it for imperative, one-off deferrals where publishing a full domain event is overkill.

open as a page

Design a reliable event-publishing mechanism that fixes the dual-write gap left by AFTER_COMMIT. Explain the transactional outbox pattern, its delivery semantics, and how it relates to Spring Modulith's event publication registry.

level: principalimportance: should knowfreq 45%

basics

~20 s

Write the event into an outbox table in the same transaction as the business data, so both commit atomically. A separate relay reads the outbox and publishes to the broker, marking rows done and retrying failures. This gives at-least-once delivery; consumers must be idempotent. Spring Modulith's event publication registry is an in-process version of this.

open as a page