Application Events

Decouple side effects from core logic by publishing events with ApplicationEventPublisher and handling them in @EventListener methods.

Syntaxevents.publishEvent(new SomethingHappened(...)); @EventListener void on(SomethingHappened event) { ... } @EventListener(condition = "#event.total > 1000") @Async @EventListener @EventListener(ApplicationReadyEvent.class)

When an order is placed, several things should follow: an audit entry, a confirmation email, a loyalty points update. Calling each of those from OrderService ties the service to every feature that cares about orders. Application events invert that: the service announces what happened, and each feature listens for it.

Publishing and listening

An event is any object; a record named in the past tense works well. Inject ApplicationEventPublisher and call publishEvent. Any bean method annotated with @EventListener whose parameter type matches is called.

public record OrderPlaced(long orderId, long customerId, BigDecimal total) {}

events.publishEvent(new OrderPlaced(order.getId(), customerId, total));

@EventListener
void on(OrderPlaced event) { ... }

Synchronous by default

This surprises people: listeners run on the publisher's thread, one after another, before publishEvent returns. That means:

  • A slow listener makes the HTTP request slow.
  • An exception in a listener propagates to the publisher, and if the publisher is @Transactional, the whole order rolls back because the email template had a typo.
  • Listeners run inside the publisher's transaction, so they see uncommitted data and their writes commit or roll back with it.

Add @Async to a listener (with @EnableAsync) to run it on the task executor instead. For side effects that must only happen once the data is committed, such as emails, use @TransactionalEventListener, covered in the transactions stage. Without it, an async listener can send a confirmation for an order that then rolls back.

Shaping listeners

@Order sets the order of synchronous listeners for the same event. condition takes a SpEL expression such as "#event.total > 1000". A listener that returns a non-null value publishes it as a new event, which is neat but makes the flow harder to follow; prefer publishing explicitly.

Spring Boot publishes its own lifecycle events too. ApplicationReadyEvent fires once the application is started and ready to serve, which makes it the right place to warm a cache, rather than a constructor or @PostConstruct.

Limits

Events stay inside one JVM and live only in memory. If the process dies after the commit and before an async listener runs, that work is gone. For anything that must reliably reach another service, store it in an outbox table or send it through a message broker. Events are for decoupling code within one application, not for integration between applications.

Example

Example · java
import java.math.BigDecimal;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;

record OrderPlaced(long orderId, long customerId, BigDecimal total) {
}

@Configuration
@EnableAsync
class AsyncConfig {
}

@Service
public class OrderService {

    private final ApplicationEventPublisher events;

    public OrderService(ApplicationEventPublisher events) {
        this.events = events;
    }

    public long place(long customerId, BigDecimal total) {
        long orderId = 1001L;
        events.publishEvent(new OrderPlaced(orderId, customerId, total));
        return orderId;
    }
}

@Component
class AuditListener {

    private static final Logger log = LoggerFactory.getLogger(AuditListener.class);

    @EventListener
    void record(OrderPlaced event) {
        log.info("Order {} placed by customer {}", event.orderId(), event.customerId());
    }
}

@Component
class LoyaltyListener {

    @Async
    @EventListener
    void award(OrderPlaced event) {
        long points = event.total().longValue();
        // add points for event.customerId()
    }
}

When to use it

  • An order service publishes OrderPlaced and separate listeners write the audit log and award loyalty points without the service knowing about either.
  • A user service publishes AccountClosed so the billing feature can cancel subscriptions and the search feature can remove the profile.
  • A catalogue API warms its country and currency caches in an ApplicationReadyEvent listener before the first request arrives.

More examples

Ordering and conditional listeners

Lower @Order values run first among synchronous listeners. The condition is evaluated before the call, so small orders never enter flagLargeOrder. SpEL compares a BigDecimal with a plain number by converting both to BigDecimal, so #event.total > 1000 works even though Java itself has no > for BigDecimal.

Example · java
import java.math.BigDecimal;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

record OrderPlaced(long orderId, long customerId, BigDecimal total) {
}

@Component
public class FraudChecks {

    private static final Logger log = LoggerFactory.getLogger(FraudChecks.class);

    @Order(1)
    @EventListener
    void basicChecks(OrderPlaced event) {
        log.debug("Basic checks for order {}", event.orderId());
    }

    @Order(2)
    @EventListener(condition = "#event.total > 1000")
    void flagLargeOrder(OrderPlaced event) {
        log.info("Order {} over 1000 flagged for review", event.orderId());
    }
}

Warming a cache when the application is ready

ApplicationReadyEvent arrives after the context is refreshed and runners have finished, so every bean, data source and cache is available. The listener runs before Spring Boot reports the application as ready to accept traffic, so on Kubernetes the readiness probe keeps requests away until the cache is warm. Keep it quick: tests that start the full application run it too, and a slow warm-up delays every one of them.

Example · java
import java.util.Map;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Component
public class CacheWarmer {

    private final CacheManager cacheManager;

    public CacheWarmer(CacheManager cacheManager) {
        this.cacheManager = cacheManager;
    }

    @EventListener(ApplicationReadyEvent.class)
    public void warm() {
        Cache countries = cacheManager.getCache("countries");
        if (countries == null) {
            return;
        }
        Map.of("GB", "United Kingdom", "IE", "Ireland", "FR", "France", "DE", "Germany")
                .forEach(countries::putIfAbsent);
    }
}

Asserting that an event was published

@RecordApplicationEvents captures every event published during each test, and ApplicationEvents.stream(type) filters them. Records compare by value, so the assertion reads naturally. Testing the publisher and each listener separately keeps both sides small.

Example · java
import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.test.context.event.ApplicationEvents;
import org.springframework.test.context.event.RecordApplicationEvents;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;

@SpringJUnitConfig
@RecordApplicationEvents
class AccountServiceEventsTest {

    record AccountClosed(long accountId) {
    }

    static class AccountService {
        private final ApplicationEventPublisher events;

        AccountService(ApplicationEventPublisher events) {
            this.events = events;
        }

        void close(long accountId) {
            events.publishEvent(new AccountClosed(accountId));
        }
    }

    @Configuration
    static class Config {
        @Bean
        AccountService accountService(ApplicationEventPublisher events) {
            return new AccountService(events);
        }
    }

    @Autowired
    AccountService service;

    @Autowired
    ApplicationEvents events;

    @Test
    void publishesAccountClosed() {
        service.close(7L);

        assertThat(events.stream(AccountClosed.class))
                .containsExactly(new AccountClosed(7L));
    }
}

Discussion

  • Be the first to comment on this lesson.