An order is placed. The kitchen needs a ticket, the customer needs an email, a message goes onto a queue, analytics wants a row. Wire all four into the checkout code and you have a method with four extra dependencies and four new ways for something unrelated to break a payment.
Events invert that. The order code announces what happened and stops caring who listens.
Publishing
Since Spring 4.2 an event can be any object — no extends ApplicationEvent — so a
record is the natural shape:
public record OrderPlacedEvent(
UUID orderPublicId, String contactEmail, BigDecimal total, OrderType orderType) {
static OrderPlacedEvent from(CustomerOrder order) {
return new OrderPlacedEvent(
order.getPublicId(), order.contactEmail(), order.getTotal(), order.getOrderType());
}
}Inject ApplicationEventPublisher and publish:
@Autowired
private ApplicationEventPublisher events;
@Transactional
public OrderCreateResponseDTO createOrder(OrderCreateDTO dto, String userEmail) {
// … price it, save it, talk to Stripe …
// Announce it and stop caring who acts on it.
events.publishEvent(OrderPlacedEvent.from(saved));
return new OrderCreateResponseDTO(toDtoWithItems(saved.getPublicId()), clientSecret);
}Carry values, not entities. The record holds the id and the totals rather than the
CustomerOrder. An async listener runs on a different thread, after the transaction and
its persistence context have closed, so touching a lazy association on a detached entity there throws
LazyInitializationException. Copying the few fields a listener needs sidesteps the whole
problem — and it also stops a listener quietly mutating the entity.
Listening
@Slf4j
@Component
@RequiredArgsConstructor
public class OrderPlacedListener {
private final CustomerOrderService orderService;
private final MailService mailService;
private final OrderMessagePublisher messagePublisher;
@Order(10)
@EventListener
public void routeToKitchen(OrderPlacedEvent event) {
log.info("Kitchen ticket queued for order {} ({})",
event.orderPublicId(), event.orderType());
}
}The method parameter type is the subscription — Spring routes by it. Multiple listeners
can take the same event, and @Order sets their sequence, lower first. Without it the
order is unspecified, which is fine right up until one listener starts depending on another having
run.
⚠️ Transaction phase — the mistake that matters
A plain @EventListener runs immediately and synchronously, inside the
publisher's transaction. The publishing method has not committed yet. It may still roll
back.
So this is a real bug:
// WRONG for anything with an effect outside the database.
@EventListener
public void sendConfirmation(OrderPlacedEvent event) {
mailService.sendOrderConfirmation(/* … */); // sent before commit
}If the transaction rolls back a millisecond later, the customer is holding an email about an order that does not exist. It works in every test until the one time the transaction fails, which is exactly when you least want to be sending confident emails.
@TransactionalEventListener defers until the transaction reaches a phase you
name:
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sendConfirmation(OrderPlacedEvent event) {
try {
mailService.sendOrderConfirmation(
orderService.getOrderByPublicId(event.orderPublicId()));
} catch (Exception ex) {
log.error("Confirmation for order {} failed — the order itself is unaffected",
event.orderPublicId(), ex);
}
}| Phase | Runs | Use for |
|---|---|---|
AFTER_COMMIT (default) | after a successful commit | email, queues, external calls — nearly always this |
AFTER_ROLLBACK | after a rollback | compensating actions |
AFTER_COMPLETION | either way | cleanup |
BEFORE_COMMIT | just before commit | rare — last-moment validation |
The decision rule is simple: if the work must be undone when the transaction rolls back,
use @EventListener. If it cannot be undone, use AFTER_COMMIT.
Kitchen routing rolls back with the order, so it is a plain listener. Email cannot be un-sent, so it
is AFTER_COMMIT.
⚠️ A @TransactionalEventListener with no active transaction does not
run at all by default. If a listener mysteriously never fires, check whether the publisher
was actually transactional. fallbackExecution = true makes it run anyway.
Async listeners
@Async moves the listener onto a thread pool. Note that the two annotations do
different jobs and you often want both: AFTER_COMMIT decides when,
@Async decides on which thread.
Without @Async, listeners are synchronous — a slow one delays the HTTP response, and
five slow ones delay it five times.
Two consequences worth knowing:
- An exception from an
@Async voidmethod is invisible to the caller. It goes to theAsyncUncaughtExceptionHandlerand nowhere near the customer, who has already been told the order succeeded — which it did. That is why the listener above catches and logs: a failed confirmation email is not a failed order, and the method has to behave that way. - The transaction is gone. A new thread has no persistence context, which is the other half of why events carry values rather than entities.
@Async requires @EnableAsync and a configured executor — lesson 28.
Built-in lifecycle events
Spring publishes its own, and they are occasionally very useful:
@EventListener(ApplicationReadyEvent.class)
public void onReady() {
log.info("Application is up and serving");
}ApplicationReadyEvent fires once everything is initialised and the server is
accepting requests. It is the correct place for startup work that needs a fully-built context —
better than @PostConstruct, which runs while the context is still being assembled.
When not to use events
Events have the same cost as AOP: they hide the call graph. "What happens when an order is placed?" becomes a search for listeners rather than a read of one method.
Good fit: genuinely independent reactions, several consumers, things that must happen after commit, and breaking a circular dependency between two services.
Bad fit: a step the caller depends on. If createOrder needs a result
back, call a method — an event has no return value, and faking one with shared mutable state is
worse than the coupling you were avoiding.
These are still in-process events, delivered in the same JVM. They are not a message queue: nothing is persisted, and a crash loses anything in flight. When you need delivery guarantees across services, that is lesson 30.
What to take from this
- Publish values, not entities — async listeners have no persistence context.
@EventListenerruns inside the transaction; use@TransactionalEventListener(AFTER_COMMIT)for anything that cannot be undone.@Asyncchooses the thread, the phase chooses the timing — they are independent and you often want both.- An
@Async voidexception vanishes. Catch and log it deliberately.
Next: Spring Web MVC — how a request actually reaches your method.