Spring Boot – Application Events

June 27, 20265 min readUpdated 8/18/2026

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);
    }
}
PhaseRunsUse for
AFTER_COMMIT (default)after a successful commitemail, queues, external calls — nearly always this
AFTER_ROLLBACKafter a rollbackcompensating actions
AFTER_COMPLETIONeither waycleanup
BEFORE_COMMITjust before commitrare — 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 void method is invisible to the caller. It goes to the AsyncUncaughtExceptionHandler and 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.
  • @EventListener runs inside the transaction; use @TransactionalEventListener(AFTER_COMMIT) for anything that cannot be undone.
  • @Async chooses the thread, the phase chooses the timing — they are independent and you often want both.
  • An @Async void exception vanishes. Catch and log it deliberately.

Next: Spring Web MVC — how a request actually reaches your method.