Events and Messaging: In-Process, Then Across Services
Spring's application events, the @TransactionalEventListener that fixes the commit-then-publish problem, and what changes when a real broker is involved.
When a Method Grows Five Responsibilities
@Transactional
public Order place(NewOrderRequest request) {
Order order = repository.save(request.toOrder());
emailService.sendConfirmation(order);
inventoryService.reserve(order);
analyticsService.recordPlacement(order);
loyaltyService.awardPoints(order);
return order;
}Placing an order is one responsibility; the other four are things that happen because an order was placed. Every new reaction edits this method, and OrderService acquires a dependency on every subsystem in the application.
Events invert that. The order service announces what happened; interested parties subscribe.
Application Events
public record OrderPlacedEvent(Long orderId, String customerEmail, BigDecimal total) { }@Service
public class OrderService {
private final ApplicationEventPublisher events;
@Transactional
public Order place(NewOrderRequest request) {
Order order = repository.save(request.toOrder());
events.publishEvent(new OrderPlacedEvent(order.getId(),
order.getCustomerEmail(), order.getTotal()));
return order;
}
}@Component
public class OrderNotificationListener {
@EventListener
public void onOrderPlaced(OrderPlacedEvent event) {
emailService.sendConfirmation(event.customerEmail(), event.orderId());
}
}OrderService now depends on none of the four subsystems. Adding a fifth reaction means adding a listener, with no change to the service — the open/closed principle, delivered by the container.
Events need no interface (since Spring 4.2, any object works), and a record is the natural shape: immutable, with an obvious constructor.
By default, @EventListener is synchronous and runs in the caller's thread and transaction. This surprises people who assume "event" means "asynchronous". A slow listener slows the publisher; a listener that throws propagates into the publisher and rolls back the transaction. The decoupling is structural, not temporal.
That default has a direct consequence. In the code above, if sendConfirmation throws, the order is rolled back — an email failure cancels a successful order. Which is almost certainly wrong.
The Commit Timing Problem
The subtler issue with publishing inside a transaction:
@Transactional
public Order place(NewOrderRequest request) {
Order order = repository.save(request.toOrder());
events.publishEvent(new OrderPlacedEvent(...)); // listener runs NOW
doSomethingThatThrows(); // transaction rolls back
return order;
}The listener ran and sent a confirmation email. The transaction then rolled back, so the order does not exist and the customer has an email about it. Worse, a listener that reads the database in another transaction may not see the uncommitted order at all.
@TransactionalEventListener fixes this by deferring to a transaction phase:
@Component
public class OrderNotificationListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderPlaced(OrderPlacedEvent event) {
emailService.sendConfirmation(event.customerEmail(), event.orderId());
}
}| Phase | Runs |
|---|---|
BEFORE_COMMIT | Before commit — can still veto by throwing |
AFTER_COMMIT (default) | After successful commit |
AFTER_ROLLBACK | After a rollback |
AFTER_COMPLETION | After either |
AFTER_COMMIT is what you want for side effects: the email is sent only if the order really exists.
AFTER_COMMIT listeners run after the transaction is committed, so database writes in such a listener have no active transaction and will fail — or silently join a new one, depending on configuration. If a listener needs to write, annotate it @Transactional(propagation = Propagation.REQUIRES_NEW). And be clear about what you've accepted: that second transaction can fail independently, so the order commits and the follow-up work doesn't. That's the dual-write problem, and the outbox pattern below is its proper answer.
Asynchronous Listeners
To stop a slow listener blocking the publisher:
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderPlaced(OrderPlacedEvent event) { }Requires @EnableAsync and — as the next guide insists — a properly configured executor.
Two things change when a listener goes async, and both are easy to overlook. Exceptions no longer propagate to the publisher; they vanish into the executor unless you configure an exception handler, so an async listener must handle its own failures. And the event is now a fire-and-forget in-memory handoff: if the process dies between commit and the listener running, that work is lost with no record it was pending.
Ordering and Conditions
@EventListener
@Order(1)
public void first(OrderPlacedEvent event) { }
@EventListener(condition = "#event.total > 1000")
public void onLargeOrder(OrderPlacedEvent event) { }@Order only matters for synchronous listeners. If two listeners must run in a defined sequence, that ordering is a dependency between them — and a dependency between things that are supposed to be decoupled is worth questioning. Often the right answer is one listener that calls both in order.
Where In-Process Events Stop Being Enough
Application events are in-memory, single-process, and not durable. They are excellent for decoupling within one deployable and wrong for anything that must survive a crash or reach another service.
| Application events | Message broker | |
|---|---|---|
| Scope | One JVM | Across services |
| Durable | No | Yes |
| Survives a restart | No | Yes |
| Retry / dead-letter | No | Yes |
| Ordering guarantees | Publication order | Per partition/queue |
| Setup | None | A broker to operate |
The deciding question: if this process crashed right now, would losing the event be acceptable? For a cache refresh, yes. For "charge this customer", no.
Moving to a Broker
RabbitMQ (spring-boot-starter-amqp) — a traditional broker. Queues, exchanges, routing keys; messages are consumed and removed. Good for work distribution and RPC-style messaging.
@Component
public class OrderEventPublisher {
private final RabbitTemplate rabbit;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void publish(OrderPlacedEvent event) {
rabbit.convertAndSend("orders.exchange", "order.placed", event);
}
}@Component
public class InventoryListener {
@RabbitListener(queues = "inventory.order-placed")
public void onOrderPlaced(OrderPlacedEvent event) {
inventoryService.reserve(event.orderId());
}
}Kafka (spring-kafka) — a distributed log. Messages are retained and replayable, consumers track their own offset, and ordering holds within a partition. Good for event streaming, multiple independent consumers, and replay. The Kafka track covers the model properly.
Pick by whether consumers need to replay history (Kafka) or simply process and acknowledge (RabbitMQ).
Two Things Every Consumer Needs
Idempotency
Brokers deliver at least once. A consumer that acknowledges after processing may crash between the two, and the message is redelivered. So every consumer must tolerate seeing the same message twice.
@RabbitListener(queues = "inventory.order-placed")
@Transactional
public void onOrderPlaced(OrderPlacedEvent event) {
if (processedRepository.existsByMessageId(event.messageId())) {
return; // already handled
}
inventoryService.reserve(event.orderId());
processedRepository.save(new Processed(event.messageId()));
}Recording the ID and the work in one transaction is what makes this correct — otherwise you can do the work and fail to record it, or vice versa. This is the same reasoning as the idempotency keys in the outbound HTTP guide; the problem is identical, pointed the other way.
A dead-letter queue
A message that always fails — malformed, or referencing deleted data — will be redelivered forever, consuming the consumer indefinitely. A dead-letter queue takes it out of the way after N attempts:
spring:
rabbitmq:
listener:
simple:
retry:
enabled: true
max-attempts: 3
initial-interval: 1s
multiplier: 2
default-requeue-rejected: false # send to DLQ rather than requeuedefault-requeue-rejected: false is the important one. Left at true, a permanently failing message is requeued immediately and spins in a tight loop — a poison-message livelock that looks like a busy consumer doing nothing.
And a dead-letter queue needs monitoring. An unwatched DLQ is a folder where lost work accumulates silently; alert on its depth.
Check yourself
A listener publishes to RabbitMQ inside the same @Transactional method that saves the order. The broker is reachable but the transaction later rolls back. What has happened?
The Outbox Pattern, Briefly
The remaining gap: commit the order and publish the message atomically, when they live in different systems.
The outbox pattern makes the publication part of the database transaction:
- In the same transaction as the business write, insert a row into an
outboxtable. - A separate process polls the outbox (or reads the database's change log) and publishes to the broker.
- On successful publish, mark the row sent.
Now there is only one transactional write, so the two can't diverge. Delivery becomes at-least-once — which your consumers already tolerate, because they're idempotent.
This is the standard answer to the dual-write problem and worth knowing by name. The distributed transactions and sagas guide covers it alongside the broader consistency patterns.
The Mental Model, Restated
- Events decouple the publisher from reactions — new behaviour means a new listener, not an edited service.
@EventListeneris synchronous and in-transaction by default. A throwing listener rolls back the publisher.- Use
@TransactionalEventListener(AFTER_COMMIT)for side effects, so they only happen if the commit did. - A listener writing after commit needs
REQUIRES_NEW. - Async listeners swallow their own exceptions and lose events on a crash.
- Application events are in-memory and not durable. Use a broker when losing one is unacceptable.
- Every consumer must be idempotent, recording the message ID in the same transaction as the work.
- Configure a DLQ with
default-requeue-rejected: false, and monitor its depth.
What's Next
Async listeners and @Async have both now been mentioned with a warning about executors attached. The final guide in this phase covers that properly: @Scheduled with its fixed-rate, fixed-delay and cron variants, @Async and why the default executor is a hazard, and what changes when several instances all run the same schedule.