Spring Boot Events — The Complete Guide
Spring Events are the most underused feature in Spring Boot. Engineers reach for Kafka when they need a simple in-process pub-sub. Events sit right in the middle — loose coupling within a single service, no external infrastructure needed. One service publishes "something happened," zero or more listeners react independently. The publisher never knows who is listening. That is the entire contract.
This page covers everything: internals, production patterns, transaction binding, async pitfalls, Spring Modulith integration, and the interview questions that actually get asked.
1. What Are Spring Events
One-liner for interviews
Spring Events implement the Observer pattern inside the ApplicationContext — in-process pub-sub that is synchronous by default, requires no external broker, and enables loose coupling between components within a single deployable unit.
What it does: Lets any Spring bean publish a message that any other Spring bean can listen to, without either side knowing about the other at compile time.
Why it exists: Without events, Service A calls Service B calls Service C directly. Tight coupling. Adding a new side effect (audit log, metrics, notification) means modifying the original service. Events flip this — the original service stays untouched; new listeners register themselves.
When to use: One action triggers multiple independent side effects. You want Open/Closed Principle. You need transactional safety for side effects.
How it works internally: ApplicationEventMulticaster holds a registry of listeners. When you call publishEvent(), the multicaster iterates listeners, matches by event type, and invokes them. Synchronous = same thread, sequential. Async = submitted to a configured Executor.
%%{init: {'theme': 'base', 'themeVariables': {'fontSize': '13px', 'fontFamily': 'Inter, -apple-system, sans-serif'}, 'flowchart': {'nodeSpacing': 30, 'rankSpacing': 50, 'padding': 12, 'curve': 'basis'}, 'sequence': {'actorMargin': 60, 'messageMargin': 40}, 'class': {'padding': 12}}}%%
flowchart LR
P["OrderService<br/>publishes event"] -->|"publishEvent()"| M["ApplicationEventMulticaster"]
M --> L1["EmailListener"]
M --> L2["InventoryListener"]
M --> L3["AuditListener"]
M --> L4["MetricsListener"]
style M fill:#FEF3C7,stroke:#FCD34D,stroke-width:2px,color:#1E40AF What breaks
Spring Events are fire-and-forget within a single JVM. If the application crashes mid-processing, the event is gone. No retry, no replay, no durability. If you need guaranteed delivery, you need a message broker or the Spring Modulith Event Publication Registry.
2. Core Components
ApplicationEvent (Legacy Base Class)
Before Spring 4.2, all events had to extend ApplicationEvent. Since 4.2, any POJO works. You will still see legacy code using the base class.
public class OrderPlacedEvent extends ApplicationEvent {
private final String orderId;
private final String userId;
public OrderPlacedEvent(Object source, String orderId, String userId) {
super(source);
this.orderId = orderId;
this.userId = userId;
}
public String getOrderId() { return orderId; }
public String getUserId() { return userId; }
}
Interview Tip
"Since Spring 4.2, events don't need to extend ApplicationEvent. A plain POJO or Java record is the idiomatic approach. The framework resolves the event type from the listener's method parameter."
ApplicationEventPublisher
The interface you inject to fire events. ApplicationContext itself implements it, but always depend on the narrower ApplicationEventPublisher interface.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher eventPublisher;
@Transactional
public Order placeOrder(CreateOrderRequest request) {
Order order = orderRepository.save(Order.from(request));
eventPublisher.publishEvent(new OrderPlacedEvent(
order.getId(),
order.getUserId(),
order.getTotalAmount(),
order.getItemIds(),
Instant.now()
));
return order;
}
}
@EventListener (Annotation-Based)
Place on any method of a Spring-managed bean. Spring resolves the event type from the method parameter at startup.
@Component
@Slf4j
public class OrderEmailListener {
private final EmailService emailService;
@EventListener
public void onOrderPlaced(OrderPlacedEvent event) {
log.info("Sending confirmation for order {}", event.orderId());
emailService.sendOrderConfirmation(event.userId(), event.orderId());
}
}
ApplicationListener Interface (Legacy)
Older approach. Still valid for programmatic registration or when you need to register listeners before the context fully starts.
@Component
public class InventoryReservationListener implements ApplicationListener<OrderPlacedEvent> {
@Override
public void onApplicationEvent(OrderPlacedEvent event) {
inventoryService.reserve(event.itemIds());
}
}
Counter-question: When would you use ApplicationListener over @EventListener?
Two scenarios: (1) You need to register a listener programmatically via context.addApplicationListener() — for example in a library that does not control component scanning. (2) You need to listen to very early lifecycle events (like ApplicationEnvironmentPreparedEvent) that fire before @EventListener annotation processing is active. For everything else, prefer @EventListener.
3. Publishing Events
From Service Methods (Standard Pattern)
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentGateway gateway;
private final PaymentRepository paymentRepository;
private final ApplicationEventPublisher events;
@Transactional
public Payment processPayment(PaymentRequest request) {
PaymentResult result = gateway.charge(request);
Payment payment = paymentRepository.save(Payment.from(request, result));
if (result.isSuccessful()) {
events.publishEvent(new PaymentCompletedEvent(
payment.getId(), payment.getOrderId(), payment.getAmount()));
} else {
events.publishEvent(new PaymentFailedEvent(
payment.getId(), payment.getOrderId(), result.getFailureReason()));
}
return payment;
}
}
Domain Events with AbstractAggregateRoot (Spring Data)
Spring Data provides AbstractAggregateRoot for DDD-style domain events. Events are collected on the entity and published automatically when save() is called.
@Entity
public class Order extends AbstractAggregateRoot<Order> {
@Id
private String id;
private String userId;
private OrderStatus status;
private BigDecimal totalAmount;
public Order place() {
this.status = OrderStatus.PLACED;
// Register event — published when repository.save(this) is called
registerEvent(new OrderPlacedEvent(this.id, this.userId, this.totalAmount,
this.getItemIds(), Instant.now()));
return this;
}
public Order cancel(String reason) {
this.status = OrderStatus.CANCELLED;
registerEvent(new OrderCancelledEvent(this.id, reason, Instant.now()));
return this;
}
}
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository repository;
@Transactional
public Order placeOrder(CreateOrderRequest request) {
Order order = Order.from(request).place();
return repository.save(order); // Events published here automatically
}
}
One-liner for interviews
"AbstractAggregateRoot.registerEvent() collects domain events on the entity itself. Spring Data publishes them when save() is called on the repository — no need to inject ApplicationEventPublisher into domain objects."
What breaks
Events registered via registerEvent() are cleared after publication. If save() is called multiple times, only events registered since the last save are published. Also, if you call registerEvent() but never call save(), the events are never published — they silently disappear.
4. @EventListener Deep Dive
Conditional Listening with SpEL
@Component
public class FraudDetectionListener {
// Only fires for orders above $500
@EventListener(condition = "#event.totalAmount.compareTo(T(java.math.BigDecimal).valueOf(500)) > 0")
public void flagHighValueOrder(OrderPlacedEvent event) {
fraudService.analyze(event.orderId());
}
// Only fires for specific users
@EventListener(condition = "#event.userId.startsWith('VIP-')")
public void prioritizeVipOrder(OrderPlacedEvent event) {
priorityQueue.escalate(event.orderId());
}
}
The condition uses SpEL. The event is accessible as #event, #root.event, or by parameter name.
Return Type Chaining
A listener can return an event (or Collection<Object>) — Spring automatically publishes the returned value as a new event.
@Component
public class OrderWorkflowListener {
@EventListener
public InventoryReservedEvent onOrderPlaced(OrderPlacedEvent event) {
inventoryService.reserve(event.itemIds());
return new InventoryReservedEvent(event.orderId(), event.itemIds());
// This return value is published as a new event
}
@EventListener
public ShipmentInitiatedEvent onInventoryReserved(InventoryReservedEvent event) {
shipmentService.initiate(event.orderId());
return new ShipmentInitiatedEvent(event.orderId());
}
}
Return null to suppress chaining. Only works with synchronous listeners.
Ordering with @Order
@Component
public class OrderProcessingListeners {
@Order(1) // Executes first — validate
@EventListener
public void validateOrder(OrderPlacedEvent event) {
validationService.validate(event);
}
@Order(2) // Executes second — reserve inventory
@EventListener
public void reserveInventory(OrderPlacedEvent event) {
inventoryService.reserve(event.itemIds());
}
@Order(3) // Executes third — send email
@EventListener
public void notifyCustomer(OrderPlacedEvent event) {
emailService.sendConfirmation(event.userId(), event.orderId());
}
}
Lower value = higher priority. Critical: @Order has zero effect on @Async listeners — they execute in parallel on separate threads regardless.
Listening to Multiple Event Types
@EventListener({OrderPlacedEvent.class, OrderCancelledEvent.class})
public void auditOrderLifecycle(Object event) {
auditService.log(event);
}
5. @TransactionalEventListener
One-liner for interviews
"@TransactionalEventListener binds listener execution to a transaction phase. AFTER_COMMIT guarantees the listener only fires if the database write succeeded — preventing emails for orders that were rolled back."
What it does: Delays listener execution until a specific point in the transaction lifecycle.
Why it exists: A plain @EventListener fires immediately when publishEvent() is called — inside the transaction. If the transaction later rolls back, the side effect (email, notification, external API call) already happened. Irreversible damage.
How it works internally: The TransactionalApplicationListenerMethodAdapter registers a TransactionSynchronization with the current TransactionSynchronizationManager. The actual listener invocation is deferred to the corresponding callback (beforeCommit, afterCommit, afterCompletion).
The Four Phases
@Component
@Slf4j
public class OrderTransactionalListeners {
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void beforeCommit(OrderPlacedEvent event) {
// Runs INSIDE the transaction, before commit.
// Can still throw and trigger rollback.
auditLog.recordInTransaction(event);
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void afterCommit(OrderPlacedEvent event) {
// Transaction committed successfully. DB write is durable.
// Safe for emails, notifications, external API calls.
emailService.sendOrderConfirmation(event.orderId());
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void afterRollback(OrderPlacedEvent event) {
// Transaction was rolled back. Order does NOT exist in DB.
log.warn("Order {} failed, notifying support", event.orderId());
alertService.notifyOrderFailure(event.orderId());
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
public void afterCompletion(OrderPlacedEvent event) {
// Fires regardless of commit or rollback. Always executes.
metricsService.recordOrderAttempt(event.orderId());
}
}
| Phase | When | Transaction State | Use Case |
|---|---|---|---|
BEFORE_COMMIT | Inside TX, before commit | Active, can rollback | Validation, audit logging inside TX |
AFTER_COMMIT | After successful commit | Committed, durable | Emails, notifications, external calls |
AFTER_ROLLBACK | After rollback | Rolled back | Alerting, compensation |
AFTER_COMPLETION | After commit OR rollback | Completed | Metrics, resource cleanup |
fallbackExecution
@TransactionalEventListener(
phase = TransactionPhase.AFTER_COMMIT,
fallbackExecution = true // Execute even when no transaction is active
)
public void handleEvent(OrderPlacedEvent event) {
emailService.send(event.orderId());
}
Production War Story
A team had @TransactionalEventListener(AFTER_COMMIT) on their email listener. Everything worked in integration tests. In production, a scheduler called the same service method without @Transactional. The listener silently never fired. Customers received no emails for 3 days. Fix: either add fallbackExecution = true or ensure every code path that publishes events runs within a transaction.
What breaks
If you publish an event from a method with no active transaction and fallbackExecution is false (the default), the listener silently never fires. No exception, no log, no warning. The event vanishes.
6. Async Events
Setup: @Async + @EventListener
@Configuration
@EnableAsync
public class AsyncEventConfig {
@Bean("eventTaskExecutor")
public Executor eventTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("event-async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
}
@Component
@Slf4j
public class NotificationListener {
@Async("eventTaskExecutor")
@EventListener
public void sendPushNotification(OrderPlacedEvent event) {
// Runs on a separate thread. Does NOT block the publisher.
log.info("[{}] Sending push for order {}",
Thread.currentThread().getName(), event.orderId());
pushService.notify(event.userId(), "Order " + event.orderId() + " confirmed!");
}
}
%%{init: {'theme': 'base', 'themeVariables': {'fontSize': '13px', 'fontFamily': 'Inter, -apple-system, sans-serif'}, 'flowchart': {'nodeSpacing': 30, 'rankSpacing': 50, 'padding': 12, 'curve': 'basis'}, 'sequence': {'actorMargin': 60, 'messageMargin': 40}, 'class': {'padding': 12}}}%%
sequenceDiagram
participant Publisher as OrderService (main thread)
participant Multicaster as EventMulticaster
participant Pool as ThreadPool
participant Listener as NotificationListener
Publisher->>Multicaster: publishEvent(OrderPlacedEvent)
Multicaster->>Pool: submit task
Publisher->>Publisher: continues immediately (non-blocking)
Pool->>Listener: sendPushNotification(event)
Listener->>Listener: sends push notification No Transaction Context in Async Listeners
What breaks
Async listeners run on a different thread. The original transaction is gone. If your async listener calls a @Transactional method, it starts a new transaction — completely independent from the publisher's transaction. If you combine @Async with @TransactionalEventListener(AFTER_COMMIT), the listener waits for the original TX to commit, then executes asynchronously.
Error Handling for Async Listeners
By default, exceptions in async listeners are silently swallowed. Configure a handler:
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (throwable, method, params) -> {
log.error("Async event listener failed: method={}, event={}",
method.getName(), params[0], throwable);
// Optionally: publish a failure event, send alert, increment metric
alertService.notifyAsyncFailure(method.getName(), throwable);
};
}
}
Production War Story
A payment notification listener was @Async. It threw NullPointerException for 2 weeks. Nobody noticed because exceptions in async listeners are swallowed by default. Customers complained they never received payment confirmations. The fix: implement AsyncUncaughtExceptionHandler + add monitoring for async task failures.
7. Spring Boot Built-in Events
Spring Boot fires events at each stage of the application lifecycle. These are invaluable for initialization, warmup, and graceful shutdown.
%%{init: {'theme': 'base', 'themeVariables': {'fontSize': '13px', 'fontFamily': 'Inter, -apple-system, sans-serif'}, 'flowchart': {'nodeSpacing': 30, 'rankSpacing': 50, 'padding': 12, 'curve': 'basis'}, 'sequence': {'actorMargin': 60, 'messageMargin': 40}, 'class': {'padding': 12}}}%%
flowchart TD
A["ApplicationStartingEvent"] --> B["ApplicationEnvironmentPreparedEvent"]
B --> C["ApplicationContextInitializedEvent"]
C --> D["ApplicationPreparedEvent"]
D --> E["ContextRefreshedEvent"]
E --> F["ApplicationStartedEvent"]
F --> G["ApplicationReadyEvent"]
X["ApplicationFailedEvent"] -.->|"on failure"| A
X -.-> B
X -.-> C
X -.-> D
style G fill:#ECFDF5,stroke:#6EE7B7,color:#065F46
style X fill:#FEF2F2,stroke:#FCA5A5,color:#991B1B | Event | When It Fires | Typical Use |
|---|---|---|
ApplicationStartingEvent | Immediately after run() is called, before anything | Logging initialization |
ApplicationEnvironmentPreparedEvent | Environment ready, no context yet | Custom property source registration |
ApplicationContextInitializedEvent | Context created, before bean definitions loaded | Early bean registration |
ApplicationPreparedEvent | Bean definitions loaded, before refresh | Late configuration |
ContextRefreshedEvent | Context fully refreshed, all beans created | Cache warmup, connection pool init |
ApplicationStartedEvent | Context refreshed, before runners called | Pre-runner initialization |
ApplicationReadyEvent | After all CommandLineRunner/ApplicationRunner execute | App fully ready for traffic |
ApplicationFailedEvent | Startup failed with exception | Alerting, cleanup |
ContextClosedEvent | JVM shutdown hook triggered | Resource release, deregistration |
@Component
@Slf4j
public class ApplicationLifecycleListeners {
@EventListener(ApplicationReadyEvent.class)
public void onReady() {
log.info("Application ready — warming caches and registering with service discovery");
cacheWarmupService.warmAll();
serviceRegistry.register();
}
@EventListener(ContextClosedEvent.class)
public void onShutdown() {
log.info("Shutting down — deregistering from service discovery");
serviceRegistry.deregister();
}
@EventListener(ApplicationFailedEvent.class)
public void onFailure(ApplicationFailedEvent event) {
log.error("Startup failed", event.getException());
alertService.notifyStartupFailure(event.getException());
}
}
Counter-question: Why can't I use @EventListener for ApplicationStartingEvent?
@EventListener requires annotation processing, which requires beans to be initialized. ApplicationStartingEvent fires before the context exists. To listen to very early events, register a listener in META-INF/spring.factories or via SpringApplication.addListeners().
8. Event-Driven Patterns
Pattern 1: Domain Events (Bounded Context Communication)
Events represent facts that happened in the domain. Other parts of the system react to these facts.
// Domain Events
public record UserRegisteredEvent(String userId, String email, Instant registeredAt) {}
public record EmailVerifiedEvent(String userId, String email) {}
public record UserProfileCompletedEvent(String userId) {}
// Multiple listeners react to registration
@Component
public class WelcomeEmailListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sendWelcome(UserRegisteredEvent event) {
emailService.sendWelcomeEmail(event.email());
}
}
@Component
public class TrialActivationListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void activateTrial(UserRegisteredEvent event) {
subscriptionService.activateFreeTrial(event.userId());
}
}
@Component
public class AnalyticsListener {
@Async("eventTaskExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void trackRegistration(UserRegisteredEvent event) {
analytics.track("user_registered", Map.of(
"user_id", event.userId(),
"timestamp", event.registeredAt().toString()
));
}
}
Pattern 2: Event Sourcing Lite
Store events as the source of truth for aggregate state, but within a single service (not full CQRS infrastructure).
@Entity
@Table(name = "domain_events")
public class StoredEvent {
@Id @GeneratedValue
private Long id;
private String aggregateId;
private String eventType;
@Column(columnDefinition = "jsonb")
private String payload;
private Instant occurredAt;
private boolean processed;
}
@Component
public class EventStore {
private final StoredEventRepository repository;
private final ObjectMapper objectMapper;
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void persist(Object event) {
if (event.getClass().isAnnotationPresent(DomainEvent.class)) {
repository.save(StoredEvent.builder()
.eventType(event.getClass().getSimpleName())
.payload(objectMapper.writeValueAsString(event))
.occurredAt(Instant.now())
.processed(false)
.build());
}
}
}
Pattern 3: Saga Coordination (Orchestrated via Events)
// Order Saga — coordinating payment, inventory, and shipping
public record OrderPlacedEvent(String orderId, String userId,
List<String> itemIds, BigDecimal amount) {}
public record PaymentCompletedEvent(String orderId, String paymentId) {}
public record PaymentFailedEvent(String orderId, String reason) {}
public record InventoryReservedEvent(String orderId, List<String> itemIds) {}
public record InventoryInsufficientEvent(String orderId, List<String> failedItems) {}
@Component
@Slf4j
public class OrderSagaCoordinator {
private final ApplicationEventPublisher events;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderPlaced(OrderPlacedEvent event) {
// Step 1: Initiate payment
paymentService.charge(event.orderId(), event.amount());
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onPaymentCompleted(PaymentCompletedEvent event) {
// Step 2: Reserve inventory
inventoryService.reserve(event.orderId());
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onPaymentFailed(PaymentFailedEvent event) {
// Compensation: Cancel the order
log.warn("Payment failed for order {}: {}", event.orderId(), event.reason());
orderService.cancelOrder(event.orderId(), "Payment failed: " + event.reason());
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onInventoryInsufficient(InventoryInsufficientEvent event) {
// Compensation: Refund payment, cancel order
paymentService.refund(event.orderId());
orderService.cancelOrder(event.orderId(), "Inventory insufficient");
}
}
%%{init: {'theme': 'base', 'themeVariables': {'fontSize': '13px', 'fontFamily': 'Inter, -apple-system, sans-serif'}, 'flowchart': {'nodeSpacing': 30, 'rankSpacing': 50, 'padding': 12, 'curve': 'basis'}, 'sequence': {'actorMargin': 60, 'messageMargin': 40}, 'class': {'padding': 12}}}%%
sequenceDiagram
participant OS as OrderService
participant Saga as SagaCoordinator
participant PS as PaymentService
participant IS as InventoryService
participant SS as ShipmentService
OS->>Saga: OrderPlacedEvent
Saga->>PS: charge()
alt Payment Success
PS->>Saga: PaymentCompletedEvent
Saga->>IS: reserve()
alt Inventory Available
IS->>Saga: InventoryReservedEvent
Saga->>SS: initiate()
else Insufficient
IS->>Saga: InventoryInsufficientEvent
Saga->>PS: refund() [compensation]
end
else Payment Failed
PS->>Saga: PaymentFailedEvent
Saga->>OS: cancelOrder() [compensation]
end Pattern 4: CQRS Read Model Updates
// Write side publishes events
@Service
public class ProductService {
@Transactional
public void updatePrice(String productId, BigDecimal newPrice) {
Product product = productRepository.findById(productId).orElseThrow();
product.setPrice(newPrice);
productRepository.save(product);
events.publishEvent(new ProductPriceChangedEvent(productId, newPrice));
}
}
// Read side updates its denormalized view
@Component
public class ProductSearchProjection {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void updateSearchIndex(ProductPriceChangedEvent event) {
// Update Elasticsearch/Redis/denormalized table
searchIndex.updatePrice(event.productId(), event.newPrice());
}
}
@Component
public class ProductCatalogProjection {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void updateCatalogCache(ProductPriceChangedEvent event) {
catalogCache.invalidate(event.productId());
}
}
9. Spring Modulith Events
Spring Modulith takes events to the next level with guaranteed delivery, externalization, and module boundaries.
One-liner for interviews
"Spring Modulith's Event Publication Registry persists events to a database table before delivery. If the app crashes mid-processing, events are replayed on next startup — giving you at-least-once delivery without Kafka."
@ApplicationModuleListener
A shorthand combining @Async + @TransactionalEventListener(AFTER_COMMIT) + @Transactional:
@Component
public class OrderNotificationModule {
// This single annotation replaces three annotations
@ApplicationModuleListener
public void onOrderPlaced(OrderPlacedEvent event) {
notificationService.sendOrderConfirmation(event.orderId(), event.userId());
}
}
Equivalent to:
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void onOrderPlaced(OrderPlacedEvent event) { ... }
Event Publication Registry
The registry stores events in a database table (EVENT_PUBLICATION). An event is marked "completed" only after the listener successfully processes it.
// application.yml
spring:
modulith:
events:
jdbc:
schema-initialization:
enabled: true
republish-outstanding-events-on-restart: true
How it works:
- Event is published inside a transaction
- Registry persists event to
EVENT_PUBLICATIONtable (same transaction) - Transaction commits — both business data and event are durable
- Listener processes the event asynchronously
- On success: event marked as completed in the registry
- On failure/crash: incomplete events are retried on next startup
%%{init: {'theme': 'base', 'themeVariables': {'fontSize': '13px', 'fontFamily': 'Inter, -apple-system, sans-serif'}, 'flowchart': {'nodeSpacing': 30, 'rankSpacing': 50, 'padding': 12, 'curve': 'basis'}, 'sequence': {'actorMargin': 60, 'messageMargin': 40}, 'class': {'padding': 12}}}%%
flowchart TD
A["publishEvent()"] --> B["Event persisted to EVENT_PUBLICATION table<br/>(same TX as business data)"]
B --> C["TX commits"]
C --> D["Listener invoked asynchronously"]
D -->|Success| E["Mark event COMPLETED"]
D -->|Failure/Crash| F["Event stays INCOMPLETE"]
F --> G["On restart: replay incomplete events"]
G --> D
style E fill:#ECFDF5,stroke:#6EE7B7,color:#065F46
style F fill:#FEF2F2,stroke:#FCA5A5,color:#991B1B Replaying Failed Events
@Component
@RequiredArgsConstructor
public class EventReplayScheduler {
private final IncompleteEventPublications incompleteEvents;
@Scheduled(fixedDelay = 60_000) // Every minute
public void resubmitIncompleteEvents() {
incompleteEvents.resubmitIncompletePublicationsOlderThan(
Duration.ofMinutes(5));
}
}
Event Externalization (to Kafka/RabbitMQ)
Spring Modulith can automatically externalize events to a message broker based on annotations:
@Externalized("orders.placed::#{#this.orderId()}") // Topic::routing key
public record OrderPlacedEvent(String orderId, String userId, BigDecimal amount) {}
Interview Tip
"Spring Modulith bridges the gap between in-process events and external messaging. Events start as in-process, and when you need to cross service boundaries, you add @Externalized — same event class, no code change in publishers or listeners."
10. Events vs Direct Calls vs Kafka — Decision Tree
%%{init: {'theme': 'base', 'themeVariables': {'fontSize': '13px', 'fontFamily': 'Inter, -apple-system, sans-serif'}, 'flowchart': {'nodeSpacing': 30, 'rankSpacing': 50, 'padding': 12, 'curve': 'basis'}, 'sequence': {'actorMargin': 60, 'messageMargin': 40}, 'class': {'padding': 12}}}%%
flowchart TD
Q1{"Does the side effect<br/>cross service boundaries?"}
Q1 -->|Yes| Q2{"Need durability/<br/>replay?"}
Q1 -->|No| Q3{"Is loose coupling<br/>needed?"}
Q2 -->|Yes| KAFKA["Use Kafka/RabbitMQ"]
Q2 -->|No| HTTP["Direct HTTP/gRPC call"]
Q3 -->|Yes| Q4{"Need guaranteed<br/>delivery?"}
Q3 -->|No| DIRECT["Direct method call"]
Q4 -->|Yes| MODULITH["Spring Modulith Events<br/>(Event Publication Registry)"]
Q4 -->|No| SPRING["Spring Events<br/>(@TransactionalEventListener)"]
style KAFKA fill:#DBEAFE,stroke:#3B82F6,color:#1E40AF
style MODULITH fill:#EDE9FE,stroke:#8B5CF6,color:#4C1D95
style SPRING fill:#ECFDF5,stroke:#10B981,color:#065F46
style DIRECT fill:#FEF3C7,stroke:#F59E0B,color:#92400E
style HTTP fill:#FEE2E2,stroke:#EF4444,color:#991B1B | Criterion | Direct Call | Spring Events | Modulith Events | Kafka/RabbitMQ |
|---|---|---|---|---|
| Coupling | Tight | Loose | Loose | Loose |
| Scope | Same class/service | Same JVM | Same JVM | Cross-service |
| Durability | N/A | None | DB-backed | Disk-persisted |
| Delivery | Guaranteed (sync) | At-most-once | At-least-once | At-least/exactly-once |
| Retry | Manual | Manual | Automatic | Built-in |
| Infrastructure | None | None | Database table | Broker cluster |
| Latency | Nanoseconds | Microseconds | Milliseconds | Milliseconds-seconds |
| Best for | Simple, single concern | Decoupling side effects | Reliable in-process messaging | Inter-service events |
Interview Tip
"I use Spring Events when one action triggers multiple independent side effects within a single service — like sending an email, updating a cache, and recording an audit log after an order is placed. I switch to Kafka when events need to cross service boundaries or survive application restarts."
11. Production Patterns
Pattern: Complete E-Commerce Order Flow
// ===== Events =====
public record OrderPlacedEvent(String orderId, String userId,
List<OrderItem> items, BigDecimal total, Instant placedAt) {}
public record PaymentCompletedEvent(String orderId, String paymentId,
BigDecimal amount) {}
public record InventoryReservedEvent(String orderId, List<String> skus) {}
public record OrderReadyForShipmentEvent(String orderId, String warehouseId) {}
public record OrderShippedEvent(String orderId, String trackingNumber) {}
// ===== Publisher =====
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepo;
private final ApplicationEventPublisher events;
@Transactional
public Order placeOrder(CreateOrderRequest request) {
Order order = orderRepo.save(Order.create(request));
events.publishEvent(new OrderPlacedEvent(
order.getId(), order.getUserId(), order.getItems(),
order.getTotal(), Instant.now()));
return order;
}
}
// ===== Listeners =====
@Component
@RequiredArgsConstructor
@Slf4j
public class PaymentListener {
private final PaymentService paymentService;
private final ApplicationEventPublisher events;
@Order(1)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void chargeCustomer(OrderPlacedEvent event) {
log.info("Charging customer for order {}", event.orderId());
PaymentResult result = paymentService.charge(event.userId(), event.total());
events.publishEvent(new PaymentCompletedEvent(
event.orderId(), result.getPaymentId(), event.total()));
}
}
@Component
@RequiredArgsConstructor
@Slf4j
public class InventoryListener {
private final InventoryService inventoryService;
private final ApplicationEventPublisher events;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void reserveStock(PaymentCompletedEvent event) {
log.info("Reserving inventory for order {}", event.orderId());
ReservationResult result = inventoryService.reserve(event.orderId());
events.publishEvent(new InventoryReservedEvent(
event.orderId(), result.getReservedSkus()));
}
}
@Component
@RequiredArgsConstructor
public class ShipmentListener {
@Async("eventTaskExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void initiateShipment(InventoryReservedEvent event) {
shipmentService.createShipment(event.orderId(), event.skus());
}
}
@Component
@RequiredArgsConstructor
public class CustomerNotificationListener {
@Async("eventTaskExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void notifyOrderPlaced(OrderPlacedEvent event) {
emailService.sendOrderConfirmation(event.userId(), event.orderId());
smsService.sendOrderSMS(event.userId(), event.orderId());
}
@Async("eventTaskExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void notifyOrderShipped(OrderShippedEvent event) {
emailService.sendShipmentNotification(event.orderId(), event.trackingNumber());
}
}
Pattern: User Registration with Progressive Onboarding
public record UserRegisteredEvent(String userId, String email, String name,
String source, Instant registeredAt) {}
@Component
public class RegistrationListeners {
@Order(1)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sendVerificationEmail(UserRegisteredEvent event) {
String token = tokenService.generateVerificationToken(event.userId());
emailService.sendVerification(event.email(), token);
}
@Order(2)
@Async("eventTaskExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void setupDefaultPreferences(UserRegisteredEvent event) {
preferencesService.createDefaults(event.userId());
}
@Order(3)
@Async("eventTaskExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void trackInAnalytics(UserRegisteredEvent event) {
analytics.identify(event.userId(), Map.of(
"email", event.email(),
"name", event.name(),
"source", event.source(),
"registered_at", event.registeredAt().toString()
));
analytics.track("user_registered", Map.of("source", event.source()));
}
@Order(4)
@Async("eventTaskExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void notifySlack(UserRegisteredEvent event) {
if ("enterprise".equals(event.source())) {
slackService.notify("#sales", "New enterprise signup: " + event.email());
}
}
}
Pattern: Retry with Dead Letter Events
public record EventProcessingFailedEvent(
Object originalEvent,
String listenerName,
String errorMessage,
int attemptCount,
Instant failedAt
) {}
@Component
@Slf4j
public class ResilientEmailListener {
private static final int MAX_RETRIES = 3;
private final ApplicationEventPublisher events;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sendEmail(OrderPlacedEvent event) {
try {
emailService.sendOrderConfirmation(event.orderId());
} catch (EmailServiceException e) {
log.error("Email failed for order {}", event.orderId(), e);
events.publishEvent(new EventProcessingFailedEvent(
event, "sendEmail", e.getMessage(), 1, Instant.now()));
}
}
@Async("retryExecutor")
@EventListener
public void retryFailedEvents(EventProcessingFailedEvent failure) {
if (failure.attemptCount() >= MAX_RETRIES) {
log.error("Max retries exceeded for {}", failure.originalEvent());
deadLetterStore.save(failure);
return;
}
try {
Thread.sleep(Duration.ofSeconds((long) Math.pow(2, failure.attemptCount())).toMillis());
// Re-process original event
processEvent(failure.originalEvent());
} catch (Exception e) {
events.publishEvent(new EventProcessingFailedEvent(
failure.originalEvent(), failure.listenerName(),
e.getMessage(), failure.attemptCount() + 1, Instant.now()));
}
}
}
12. Testing Events
@RecordApplicationEvents (Spring Boot 3.x)
@SpringBootTest
@RecordApplicationEvents // Records all events published during the test
class OrderServiceTest {
@Autowired
private ApplicationEvents events; // Inject recorded events
@Autowired
private OrderService orderService;
@Test
void placeOrder_shouldPublishOrderPlacedEvent() {
// When
Order order = orderService.placeOrder(createOrderRequest());
// Then — verify event was published
assertThat(events.stream(OrderPlacedEvent.class))
.hasSize(1)
.first()
.satisfies(event -> {
assertThat(event.orderId()).isEqualTo(order.getId());
assertThat(event.userId()).isEqualTo("user-123");
assertThat(event.totalAmount()).isEqualByComparingTo("99.99");
});
}
@Test
void placeOrder_shouldNotPublishEventOnFailure() {
// When
assertThrows(InsufficientFundsException.class,
() -> orderService.placeOrder(invalidRequest()));
// Then — no event published
assertThat(events.stream(OrderPlacedEvent.class)).isEmpty();
}
}
Testing Listeners Directly
@SpringBootTest
class EmailListenerTest {
@Autowired
private ApplicationEventPublisher publisher;
@MockBean
private EmailService emailService;
@Test
void shouldSendEmailOnOrderPlaced() {
// Given
OrderPlacedEvent event = new OrderPlacedEvent(
"order-1", "user-1", BigDecimal.TEN, List.of("item-1"), Instant.now());
// When
publisher.publishEvent(event);
// Then
verify(emailService).sendOrderConfirmation("user-1", "order-1");
}
}
Testing @TransactionalEventListener
@SpringBootTest
@Transactional // Critical: @TransactionalEventListener needs an active TX
class TransactionalListenerTest {
@Autowired
private TestEntityManager entityManager;
@Autowired
private OrderService orderService;
@MockBean
private EmailService emailService;
@Test
void afterCommit_shouldFireAfterTransactionCommits() {
// The test transaction doesn't commit by default.
// Use TestTransaction to control commit explicitly.
orderService.placeOrder(validRequest());
TestTransaction.flagForCommit();
TestTransaction.end();
verify(emailService).sendOrderConfirmation(any(), any());
}
}
What breaks
@TransactionalEventListener(AFTER_COMMIT) does NOT fire in tests annotated with @Transactional because the test transaction rolls back by default. Use TestTransaction.flagForCommit() + TestTransaction.end(), or use @Commit on the test, or test with @SpringBootTest without @Transactional.
Testing Async Listeners
@SpringBootTest
class AsyncListenerTest {
@Autowired
private ApplicationEventPublisher publisher;
@MockBean
private PushNotificationService pushService;
@Test
void asyncListener_shouldExecuteOnSeparateThread() {
publisher.publishEvent(new OrderPlacedEvent(...));
// Async — need to wait
await().atMost(Duration.ofSeconds(5))
.untilAsserted(() ->
verify(pushService).notify(eq("user-1"), anyString()));
}
}
13. Common Pitfalls
Pitfall 1: Synchronous Default Surprises
// PROBLEM: Publisher blocks for 30 seconds while email sends
@EventListener
public void sendEmail(OrderPlacedEvent event) {
emailService.send(event); // Takes 30 seconds due to SMTP timeout
}
// FIX: Make it async
@Async("eventTaskExecutor")
@EventListener
public void sendEmail(OrderPlacedEvent event) {
emailService.send(event);
}
Pitfall 2: @TransactionalEventListener Not Firing
// PROBLEM: No active transaction when event is published
@Service
public class ScheduledService {
// Missing @Transactional!
public void processScheduledOrders() {
orders.forEach(order -> {
events.publishEvent(new OrderProcessedEvent(order.getId()));
// TransactionalEventListener NEVER fires — no active TX
});
}
}
// FIX 1: Add @Transactional
@Transactional
public void processScheduledOrders() { ... }
// FIX 2: Use fallbackExecution
@TransactionalEventListener(fallbackExecution = true)
public void onProcessed(OrderProcessedEvent event) { ... }
Pitfall 3: Circular Event Publishing
// PROBLEM: Infinite loop
@EventListener
public void onA(EventA a) {
publisher.publishEvent(new EventB()); // B listener publishes A again!
}
@EventListener
public void onB(EventB b) {
publisher.publishEvent(new EventA()); // Infinite recursion → StackOverflow
}
// FIX: Add guard conditions or use a processed-events set
@EventListener
public void onA(EventA a) {
if (!processedEvents.contains(a.correlationId())) {
processedEvents.add(a.correlationId());
publisher.publishEvent(new EventB(a.correlationId()));
}
}
Pitfall 4: Exception in Sync Listener Rolls Back Publisher
// PROBLEM: Listener exception propagates to publisher and rolls back transaction
@EventListener
public void onOrderPlaced(OrderPlacedEvent event) {
externalApi.call(); // Throws RuntimeException
// Publisher's transaction is ROLLED BACK because of this exception!
}
// FIX 1: Wrap in try-catch
@EventListener
public void onOrderPlaced(OrderPlacedEvent event) {
try {
externalApi.call();
} catch (Exception e) {
log.error("External call failed", e);
}
}
// FIX 2: Use @TransactionalEventListener(AFTER_COMMIT)
// Listener runs after commit — exception doesn't affect the publisher's TX
// FIX 3: Use @Async — listener runs on separate thread
Pitfall 5: Generic Type Erasure
// PROBLEM: Both listeners receive ALL EntityEvent instances
@EventListener
public void onOrder(EntityEvent<Order> event) { ... } // Gets Product events too!
@EventListener
public void onProduct(EntityEvent<Product> event) { ... } // Gets Order events too!
// FIX: Implement ResolvableTypeProvider on EntityEvent
public class EntityEvent<T> implements ResolvableTypeProvider {
private final T entity;
@Override
public ResolvableType getResolvableType() {
return ResolvableType.forClassWithGenerics(
getClass(), ResolvableType.forInstance(this.entity));
}
}
Pitfall 6: SimpleAsyncTaskExecutor in Production
// PROBLEM: @EnableAsync without a custom executor uses SimpleAsyncTaskExecutor
// This creates a new thread for EVERY async event — no thread reuse, no bound, OOM risk
// FIX: Always define a bounded ThreadPoolTaskExecutor
@Bean("eventTaskExecutor")
public Executor eventTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(100);
executor.setRejectedExecutionHandler(new CallerRunsPolicy());
return executor;
}
Production War Story
A startup used @EnableAsync with the default executor. Under Black Friday load, each event spawned a new thread. The JVM hit 10,000 threads, ran out of native memory, and crashed. Thread pools exist for a reason.
14. Generic Events with ResolvableTypeProvider
Java type erasure means EntityEvent<Order> and EntityEvent<Product> are the same class at runtime. Spring cannot distinguish which listener to invoke without help.
public class EntityChangedEvent<T> implements ResolvableTypeProvider {
private final T entity;
private final ChangeType changeType;
public EntityChangedEvent(T entity, ChangeType changeType) {
this.entity = entity;
this.changeType = changeType;
}
public T getEntity() { return entity; }
public ChangeType getChangeType() { return changeType; }
@Override
public ResolvableType getResolvableType() {
return ResolvableType.forClassWithGenerics(
getClass(), ResolvableType.forInstance(this.entity));
}
public enum ChangeType { CREATED, UPDATED, DELETED }
}
// Publishing
events.publishEvent(new EntityChangedEvent<>(order, ChangeType.CREATED));
events.publishEvent(new EntityChangedEvent<>(product, ChangeType.UPDATED));
// Listening — correctly receives ONLY Order events
@EventListener
public void onOrderChange(EntityChangedEvent<Order> event) {
log.info("Order {} was {}", event.getEntity().getId(), event.getChangeType());
}
// Listening — correctly receives ONLY Product events
@EventListener
public void onProductChange(EntityChangedEvent<Product> event) {
searchIndex.reindex(event.getEntity());
}
15. Interview Questions
1. What are Spring Application Events? When would you use them over direct method calls?
Spring Events implement the Observer/Pub-Sub pattern within the ApplicationContext. A component publishes an event; zero or more listeners react without the publisher knowing about them. Use them when: (1) one action triggers multiple independent side effects, (2) you want to add new behaviors without modifying existing code (Open/Closed Principle), (3) you need to decouple modules within a monolith, (4) you need transactional safety — ensuring side effects only run after successful commit.
2. Are Spring Events synchronous or asynchronous by default? What are the implications?
Synchronous by default. The publisher's thread executes all listeners sequentially and blocks until they complete. Implications: (1) A slow listener delays the publisher's response. (2) An exception in a listener propagates to and potentially rolls back the publisher's transaction. (3) All listeners share the publisher's transaction context. To make them async, add @EnableAsync and @Async("executorName") on the listener.
3. Explain @TransactionalEventListener. Why is AFTER_COMMIT crucial?
@TransactionalEventListener binds listener execution to a transaction phase. With a plain @EventListener, if you publish an event inside a @Transactional method, the listener fires immediately — even if the transaction later rolls back. This means you might send an email for an order that was never persisted. AFTER_COMMIT guarantees the listener only runs if the DB write succeeded. The four phases are: BEFORE_COMMIT, AFTER_COMMIT (default), AFTER_ROLLBACK, AFTER_COMPLETION.
4. What happens when a @TransactionalEventListener is triggered outside a transaction?
The listener silently never fires. No exception, no log, no warning. The event is discarded. This is a common source of bugs — especially when schedulers or integration code publishes events without @Transactional. Fix: set fallbackExecution = true on the annotation, which makes it execute like a plain @EventListener when no transaction is active.
5. How do you handle errors in async event listeners?
By default, exceptions in @Async listeners are silently swallowed. They do not propagate to the publisher, do not cause rollbacks, and produce no visible errors unless you configure: (1) AsyncUncaughtExceptionHandler via AsyncConfigurer, (2) wrap listener logic in try-catch with explicit logging/alerting, or (3) publish a failure event that triggers compensation logic. Never assume async failures will surface automatically.
6. What is AbstractAggregateRoot and how does it relate to events?
AbstractAggregateRoot is a Spring Data base class for DDD aggregate roots. It provides registerEvent(event) which collects domain events on the entity. When repository.save(entity) is called, Spring Data publishes all registered events and clears the list. This keeps event publishing in the domain layer without injecting ApplicationEventPublisher into domain objects.
7. How does Spring Modulith improve on standard Spring Events?
Three key improvements: (1) Event Publication Registry — persists events to a DB table, giving at-least-once delivery. If the app crashes, incomplete events are replayed on restart. (2) @ApplicationModuleListener — a single annotation that combines @Async + @TransactionalEventListener(AFTER_COMMIT) + @Transactional(REQUIRES_NEW). (3) Event Externalization — the @Externalized annotation on an event class causes it to be automatically published to Kafka/RabbitMQ without changing publisher/listener code.
8. When would you choose Spring Events vs Kafka vs direct method calls?
Direct calls: Single responsibility, tight coupling is acceptable, no side effects. Spring Events: Multiple independent side effects within the same JVM, loose coupling needed, no durability requirement. Spring Modulith Events: Same as Spring Events but need at-least-once delivery (retry, crash recovery). Kafka/RabbitMQ: Events cross service boundaries, need durability/replay, multiple consumer groups, event streaming.
9. What is ResolvableTypeProvider and why is it needed?
Java type erasure makes EntityEvent<Order> and EntityEvent<Product> indistinguishable at runtime — both are just EntityEvent. Without ResolvableTypeProvider, a listener for EntityEvent<Order> receives ALL EntityEvent instances. Implementing the interface tells Spring the actual generic type at runtime, enabling correct listener routing.
10. Can you combine @Async with @TransactionalEventListener? What is the execution order?
Yes. The execution order is: (1) Transaction commits (or rolls back). (2) The listener is matched to the transaction phase. (3) The listener is submitted to the async executor and runs on a separate thread. So the listener waits for the transaction phase, then executes asynchronously. This is ideal for non-blocking side effects that depend on successful DB commits.
11. How do you test @TransactionalEventListener in integration tests?
The main trap: tests annotated with @Transactional never commit — they roll back by default for test isolation. So AFTER_COMMIT listeners never fire. Solutions: (1) Use TestTransaction.flagForCommit() + TestTransaction.end() to force commit. (2) Use @Commit on the test method. (3) Don't annotate the test with @Transactional — let the service manage its own transaction. (4) Use @RecordApplicationEvents which captures events at publication time regardless of commit.
12. What is event chaining? What are its limitations?
If an @EventListener method returns a non-null value (or Collection<Object>), Spring automatically publishes the returned value as a new event. This enables workflows: OrderPlaced → listener returns PaymentCharged → listener returns InventoryReserved. Limitations: (1) Only works with synchronous listeners — @Async listeners' return values are Future objects, not new events. (2) null return suppresses chaining. (3) Can create hard-to-debug cascades if overused.
Quick Reference
Event Listener Annotation Comparison
| Annotation | Transaction Binding | Async | Error Behavior | Use Case |
|---|---|---|---|---|
@EventListener | None (fires immediately) | No | Exception propagates to publisher | Simple reactions, validation |
@TransactionalEventListener | Bound to TX phase | No | Exception after commit is isolated | Side effects needing TX safety |
@Async + @EventListener | None | Yes | Exception swallowed | Non-blocking fire-and-forget |
@Async + @TransactionalEventListener | Waits for TX phase, then async | Yes | Exception swallowed | Non-blocking + TX safety |
@ApplicationModuleListener | AFTER_COMMIT + REQUIRES_NEW | Yes | Retried via Event Publication Registry | Production-grade side effects |
Event Design Best Practices
| Practice | Why |
|---|---|
| Use immutable records for events | Thread safety, clear contracts |
Include a timestamp (occurredAt) | Debugging, ordering, audit trails |
| Include a correlation/trace ID | Distributed tracing across listeners |
| Keep events small — IDs, not full objects | Prevents stale data, reduces memory |
Name events in past tense (OrderPlaced, not PlaceOrder) | Events are facts that already happened |
| One event per significant domain action | Granular, composable reactions |
// Well-designed event
public record OrderPlacedEvent(
String eventId, // Unique event ID for idempotency
String orderId, // Aggregate ID
String userId, // Who triggered it
BigDecimal totalAmount, // Key business data
List<String> itemIds, // References, not full objects
String traceId, // Distributed tracing
Instant occurredAt // When it happened
) {
public OrderPlacedEvent {
Objects.requireNonNull(eventId);
Objects.requireNonNull(orderId);
Objects.requireNonNull(occurredAt);
}
}