Transactions and Events
Run side effects such as emails only after the transaction commits, using @TransactionalEventListener and its phases.
publisher.publishEvent(new OrderPlaced(id)); // inside @Transactional
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
void on(OrderPlaced event) { ... }A service saves an order and then sends a confirmation email. If the email goes out inside the transaction and the commit then fails, the customer has a confirmation for an order that does not exist. Even when the commit succeeds, a slow mail server keeps the transaction, its connection and its locks open for as long as the send takes. Side effects belong after the commit.
@TransactionalEventListener
Publish an application event from inside the transaction with ApplicationEventPublisher.publishEvent. A normal @EventListener runs immediately, inside the transaction. A method annotated with @TransactionalEventListener (from org.springframework.transaction.event) is held back and runs at a chosen phase:
AFTER_COMMIT(the default): only if the transaction committed. Use it for emails, cache eviction, notifying other systems.AFTER_ROLLBACK: only if it rolled back. Useful for alerting.AFTER_COMPLETION: after either outcome.BEFORE_COMMIT: just before commit, still inside the transaction, so a failure there rolls everything back.
@TransactionalEventListener
void on(OrderPlaced event) {
mailer.sendConfirmation(event.orderId());
}Rules that catch people out
- No transaction, no event. If the event is published outside a transaction, a transactional listener is skipped. Set
fallbackExecution = trueif it should run anyway. - Writing from an after-commit listener. The original transaction has committed, so database writes made there are not part of it. Spring refuses to start if the listener is annotated with a plain
@Transactional; use@Transactional(propagation = Propagation.REQUIRES_NEW)when the listener writes. - Failures after commit cannot roll back the original work. The order is saved even if the email fails. An exception thrown from an after-commit listener is logged and swallowed, so the HTTP caller still sees success; the listener needs its own error handling and retry.
- It runs on the request thread by default, so a slow listener still delays the HTTP response. Add
@Asyncto move it to a task executor.
Not a delivery guarantee
Events live in memory. If the process stops between the commit and the listener running, the event is gone and the email is never sent. For messages that must not be lost, such as publishing to Kafka, write an outbox row in the same transaction and deliver it separately. Use transactional events for side effects you can afford to lose occasionally or rebuild later.
Example
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionalEventListener;
class PurchaseOrder {
private Long id;
private String email;
protected PurchaseOrder() {
}
PurchaseOrder(String email) {
this.email = email;
}
Long getId() {
return id;
}
}
interface PurchaseOrderRepository extends JpaRepository<PurchaseOrder, Long> {
}
record OrderPlaced(long orderId, String email) {
}
public class OrderService {
private final PurchaseOrderRepository orders;
private final ApplicationEventPublisher events;
OrderService(PurchaseOrderRepository orders, ApplicationEventPublisher events) {
this.orders = orders;
this.events = events;
}
public long place(String email) {
PurchaseOrder order = orders.save(new PurchaseOrder(email));
events.publishEvent(new OrderPlaced(order.getId(), email));
return order.getId();
}
}
class OrderMailer {
private final JavaMailSender mail;
OrderMailer(JavaMailSender mail) {
this.mail = mail;
}
// Runs only after the order is committed; never for a rolled-back order.
void onOrderPlaced(OrderPlaced event) {
SimpleMailMessage msg = new SimpleMailMessage();
msg.setTo(event.email());
msg.setSubject("Order " + event.orderId() + " confirmed");
msg.setText("Thanks, we have your order.");
mail.send(msg);
}
}
When to use it
- Send an order confirmation email only once the order row is safely committed.
- Update per-customer statistics on a background thread without slowing the checkout response.
- Evict a cached price after the database change commits so readers never cache the old value again.
More examples
Async listener that writes in its own transaction
<code>@Async</code> moves the work to a task executor so the HTTP response is not delayed, and <code>REQUIRES_NEW</code> gives the listener its own transaction for its writes. Async listeners for orders from the same customer can run at the same time, so the counter is incremented with one atomic <code>UPDATE</code> instead of a read, change and write that would lose updates. If the stats update fails, the order stands; statistics like these can be recomputed later, which is what makes an in-memory event acceptable here.
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionalEventListener;
@Configuration
@EnableAsync
class AsyncConfig {
}
record OrderPlaced(long orderId, long customerId) {
}
@Entity
class CustomerStats {
@Id
private Long customerId;
private int orderCount;
protected CustomerStats() {
}
}
interface CustomerStatsRepository extends JpaRepository<CustomerStats, Long> {
// One atomic UPDATE: two listeners running at once cannot overwrite each other.
@Modifying
@Query("update CustomerStats s set s.orderCount = s.orderCount + 1 where s.customerId = :customerId")
int countOrder(Long customerId);
}
@Component
public class StatsUpdater {
private final CustomerStatsRepository stats;
StatsUpdater(CustomerStatsRepository stats) {
this.stats = stats;
}
@Async
@TransactionalEventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void onOrderPlaced(OrderPlaced event) {
stats.countOrder(event.customerId());
}
}
Reacting to rollbacks and events outside a transaction
The same <code>ImportFinished</code> event can drive an after-commit listener elsewhere and this after-rollback one, so success and failure handling stay separate. <code>fallbackExecution = true</code> makes a listener run immediately when there is no transaction to wait for, which is useful for events that may be published from either kind of caller.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
record ImportStarted(String fileName) {
}
record ImportFinished(String fileName, int rows) {
}
@Component
public class ImportMonitor {
private static final Logger log = LoggerFactory.getLogger(ImportMonitor.class);
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
void onRolledBack(ImportFinished event) {
log.warn("Import of {} rolled back, {} rows discarded", event.fileName(), event.rows());
}
// Published from a non-transactional entry point, so without fallbackExecution
// this listener would never run.
@TransactionalEventListener(fallbackExecution = true)
void onStarted(ImportStarted event) {
log.info("Import of {} started", event.fileName());
}
}
A lower-level hook with TransactionSynchronization
This is the mechanism transactional event listeners are built on. Registering a <code>TransactionSynchronization</code> directly suits small infrastructure code that should not depend on an event type. Evicting after commit, rather than during the transaction, stops another request reloading the old price into the cache before the new one is visible in the database.
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Component;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
@Component
public class PriceCache {
private final Map<String, Long> prices = new ConcurrentHashMap<>();
// Called from inside a write transaction when a price changes.
public void evictAfterCommit(String sku) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
prices.remove(sku);
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
prices.remove(sku);
}
});
}
public Long get(String sku) {
return prices.get(sku);
}
}
Discussion