Spring Events: Domain Events, Async Listeners, and Event Sourcing Explained
Learn Spring events from domain events to async listeners and event sourcing.
20+ years shipping production Java in banking & fintech. Lessons pulled from things that broke in production.
- ✓Java 17+ (preferably 21)
- ✓Spring Boot 3.x basic knowledge
- ✓Familiarity with dependency injection and annotations
• Spring Events decouple components via publish-subscribe pattern using ApplicationEventPublisher and @EventListener\n• Domain events represent meaningful business occurrences, enabling clean separation between aggregates and side effects\n• Async listeners require @Async and @EnableAsync to avoid blocking the publisher thread, but watch for transaction boundaries\n• Event sourcing persists every state change as an event, enabling full audit trails and temporal queries\n• In production, always handle listener exceptions with ErrorHandler or @TransactionalEventListener to prevent data inconsistency
Think of Spring Events like a restaurant kitchen. When a waiter places an order (publishes an event), the chefs (listeners) start cooking independently. The waiter doesn't wait for the food to be ready; they move on to serve other customers. If a chef drops a plate, the kitchen doesn't collapse—other chefs keep working. Event sourcing is like recording every step of cooking in a log, so you can replay the recipe later if needed.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
In any non-trivial Spring Boot application, you'll eventually face the challenge of decoupling components while maintaining data consistency. Spring Events provide a clean, framework-native solution for implementing the observer pattern without introducing external messaging systems prematurely. But here's the thing—most tutorials stop at 'how to publish and listen to events.' They don't tell you about the production pitfalls: transaction synchronization, async listener thread pools, event ordering, and the subtle bugs that can corrupt your database state. This article covers the full spectrum: basic domain events, async listeners with proper error handling, and an introduction to event sourcing—the pattern that treats events as the primary source of truth. We'll use Spring Boot 3.2.x with Java 21, focusing on real-world payment-processing and SaaS billing domains. By the end, you'll know how to design event-driven flows that survive production loads, handle failures gracefully, and avoid the common antipatterns that have burned me in the past. No fluff, just battle-tested patterns.
Setting Up Spring Events: The Basics
Spring Events are built on the observer pattern. You have three main actors: the event class, the publisher, and the listener. The event class is a simple POJO that extends ApplicationEvent or, more commonly in modern Spring, implements nothing—just a plain object. The publisher injects ApplicationEventPublisher and calls publishEvent(). The listener is a bean method annotated with @EventListener. Let's walk through a typical billing domain scenario. Imagine you have an InvoiceService that creates an invoice. After creation, you want to send an email, update a CRM, and trigger a payment workflow. Without events, InvoiceService would be coupled to all these services. With events, you publish an InvoiceCreatedEvent and let separate listeners handle each concern. Here's the event class: a record with invoice ID, amount, and customer email. The publisher is straightforward—autowire ApplicationEventPublisher and call publishEvent() inside your service method. The listener is a @Component with a method annotated with @EventListener. Spring will invoke it synchronously in the same thread by default. This is fine for simple cases, but for production, you'll almost always want async or transactional listeners. The key thing to remember: the publisher and listener share the same transaction by default. If the listener throws a RuntimeException, the publisher's transaction will roll back. This is by design—Spring treats events as part of the publisher's transaction by default. Use @TransactionalEventListener if you want to decouple the transaction lifecycle.
What the Official Docs Won't Tell You
The official Spring documentation covers the basics well, but it glosses over several critical details that will bite you in production. First, event ordering. Spring does NOT guarantee the order in which listeners receive events. If you have two listeners for the same event, they may execute in any order depending on the bean instantiation order. Use @Order annotation if you need ordering, but even then, it's fragile across application restarts. Second, exception handling. By default, if a synchronous listener throws an exception, it propagates to the publisher. But for async listeners, the exception is swallowed by the AsyncTaskExecutor unless you configure an AsyncUncaughtExceptionHandler. I've seen teams waste days debugging 'lost' events because async exceptions were silently ignored. Third, event inheritance. Spring events support inheritance—if a listener listens to a parent class, it will receive child class events. This can lead to unexpected behavior if you're not careful. For example, if you have a generic PaymentEvent and a specific CreditCardPaymentEvent, a listener for PaymentEvent will also receive CreditCardPaymentEvent. This is useful but can cause duplicate processing if you have multiple listeners. Fourth, the event payload should be immutable. I've seen mutable event objects modified by one listener causing side effects for another listener. Use records or final fields. Finally, the biggest hidden gotcha: @TransactionalEventListener with default phase (AFTER_COMMIT) only works if the publisher's method is @Transactional. If it's not, the listener never fires. This has caused countless production bugs where developers assumed the event would always fire after the transaction, but it silently disappeared because the method wasn't transactional.
Async Listeners: The Right Way to Decouple
Async listeners are the bread and butter of production Spring event systems. They allow you to offload side effects like email sending, webhook calls, or cache eviction to separate threads, keeping the main request fast. But there's a right way and a wrong way. The wrong way: just slap @Async on your listener and forget about thread pool configuration. The default SimpleAsyncTaskExecutor creates a new thread for every task—no pooling, no backpressure. Under load, this will bring down your application. The right way: configure a dedicated ThreadPoolTaskExecutor with bounded queue, core/max pool sizes, and rejection policy. Use @EnableAsync on a @Configuration class that implements AsyncConfigurer. For event-specific tuning, you can create multiple executors for different event types—one for fast, low-latency events (e.g., cache invalidation) and another for slow, I/O-bound events (e.g., email sending). This prevents a slow event from starving fast events. Another critical pattern: use @Async with @EventListener. Spring will pick up the @Async annotation on the listener method if you've also annotated it with @EventListener. But beware—the transaction context is NOT propagated to the async thread. If your listener needs transactional behavior, you must start a new transaction manually using @Transactional(propagation = Propagation.REQUIRES_NEW). Also, consider using @TransactionalEventListener with @Async for the best of both worlds: the event fires after the publisher's transaction commits, and the listener runs asynchronously. This ensures the publisher's data is visible to the listener. Finally, monitor your async executor metrics. Use Micrometer to track queue depth, active threads, and rejected tasks. Set up alerts for queue growth—it's a leading indicator of a downstream bottleneck.
Domain Events: Modeling Business Logic with Events
Domain events are a DDD (Domain-Driven Design) concept that Spring Events implement well. The idea is that your domain aggregates publish events when something meaningful happens—an order was placed, a payment was received, a subscription was cancelled. These events are part of the domain model, not infrastructure. In practice, this means your domain objects (not services) should be responsible for producing events. But here's the tension: JPA entities managed by Spring Data are not the best place for domain logic. A better pattern is to have a separate domain service that orchestrates aggregate operations and publishes events. For example, when an Order aggregate is created, the OrderService calls order.place() which returns a list of domain events. The service then publishes these events via ApplicationEventPublisher. This keeps your domain pure (no framework dependency) while leveraging Spring for infrastructure. The events themselves should be named in the past tense (OrderPlaced, PaymentReceived) because they represent facts that have already happened. They should contain only data that's relevant to listeners—not the entire aggregate state. For instance, an OrderPlacedEvent might contain order ID, customer ID, and total amount, but not the full list of line items (unless a listener needs it). This reduces coupling and event size. Another important pattern: event enrichment. Sometimes a listener needs data that isn't in the domain event. For example, an email listener needs the customer's email address, which is owned by the Customer aggregate, not the Order. In this case, the listener should fetch the data from the appropriate repository, not expect the event to carry it. This keeps events focused and avoids bloating them with every possible use case. Finally, version your domain events. Use a version field in the event class. When you change the event schema, increment the version. This allows you to have multiple versions of listeners running simultaneously during deployments.
Event Sourcing: Events as the Source of Truth
Event sourcing takes domain events to the extreme: instead of storing the current state of an aggregate, you store every event that happened to it. The current state is derived by replaying all events. This is powerful for audit trails, temporal queries, and complex business logic that needs to know 'why' the state is what it is. But it's also complex and not suitable for every system. In Spring, you can implement event sourcing without a framework like Axon, but it requires discipline. The core idea: you have an EventStore (usually a database table) that stores events in order. Each event has an aggregate ID, version, event type, and payload (usually JSON). To load an aggregate, you read all events for that aggregate ID, apply them in order to rebuild the state. To save, you append new events atomically. The tricky part is concurrency. If two requests try to append events to the same aggregate simultaneously, you can get version conflicts. The solution is optimistic locking: include the current version in the event store row, and use a unique constraint on (aggregate_id, version). When appending, check that the version is exactly what you expect. If another request already appended, the insert will fail, and you retry. Spring Data JPA can handle this with @Version annotation. Another challenge: event schema evolution. As your system grows, events change. You need to handle upcasting—transforming old event versions to the current schema during replay. This is typically done with a registry of upcasters that convert events from version N to N+1. Finally, consider snapshots. Replaying thousands of events to load an aggregate is slow. Periodically, take a snapshot of the aggregate state and store it. When loading, start from the latest snapshot and replay only events after that point. In Spring, you can implement this with a scheduled task or a listener that triggers snapshots after a threshold (e.g., every 100 events). Event sourcing is not for CRUD-heavy applications. It shines in domains like accounting, banking, and compliance where audit trails are mandatory.
Testing Event-Driven Flows: Avoiding the Integration Test Trap
Testing event-driven code is notoriously tricky. The main challenge is that events are asynchronous and side-effectful. You have three levels of testing: unit tests for the listener logic, integration tests for the event pipeline, and end-to-end tests for the full flow. For unit tests, mock the dependencies and test the listener method directly. Don't rely on Spring to inject the event—just call the method with the event object. For integration tests, use @SpringBootTest with @MockBean for external services, and verify that events were published and listeners were invoked. Spring provides a handy test utility: ApplicationEventPublisherAware or simply inject the publisher and use ArgumentCaptor to capture published events. But here's the trap: if you're testing async listeners, your test might finish before the listener executes. Use awaitility to wait for async results. For example, if your listener sends an email, use Awaitility to wait until the email mock is called. Another common issue: @TransactionalEventListener in tests. If your test method is @Transactional (which it is by default with @DataJpaTest), the transaction commits at the end of the test, so the listener never fires. Use @Transactional(propagation = Propagation.NOT_SUPPORTED) on your test method to disable the transaction, or use @Commit to force commit. For event sourcing, test the replay logic thoroughly. Create a set of events, replay them, and assert the resulting state. Also test version conflicts by simulating concurrent appends. Finally, use Testcontainers for database integration tests, especially for event sourcing with PostgreSQL. In-memory databases like H2 have subtle differences in transaction isolation that can mask bugs.
Production Patterns: Error Handling, Monitoring, and Dead Letter Queues
In production, events will fail. Network timeouts, database deadlocks, transient bugs—it's not a matter of if, but when. Your event system must handle failures gracefully. The first pattern: dead letter queues (DLQ). When a listener fails after retries, publish a FailedEvent to a separate topic or table. A separate process can inspect these failures, fix the issue, and replay them. In Spring, you can implement this with a simple table: failed_events with columns for event type, payload, error message, and retry count. A scheduled job can retry events with exponential backoff. The second pattern: idempotency. Every event should have a unique ID. Before processing, check if this event ID has already been processed. This prevents duplicates from retries. In a database, use a unique constraint on (event_id, listener_name). The third pattern: circuit breakers. If a downstream service (like an email API) is failing, don't keep hammering it. Use Resilience4j's CircuitBreaker around your listener logic. When the circuit opens, events can be sent to a DLQ immediately instead of failing repeatedly. The fourth pattern: monitoring. Expose metrics for event processing: events published, events processed successfully, events failed, processing latency, and queue depth. Use Micrometer with Prometheus and Grafana dashboards. Set up alerts for spikes in failure rate or queue growth. The fifth pattern: graceful shutdown. When your application shuts down, in-flight async events may be lost. Implement a shutdown hook that waits for active tasks to complete. Configure your ThreadPoolTaskExecutor with setWaitForTasksToCompleteOnShutdown(true) and setAwaitTerminationSeconds(30). Finally, consider event ordering guarantees. If you need strict ordering (e.g., process events in the order they were published for the same aggregate), use a single-threaded executor per aggregate ID. This is complex but necessary for some domains like banking transactions.
Performance Considerations and Scaling Event-Driven Systems
Spring Events are not a replacement for a message broker like RabbitMQ or Kafka. They are an in-process mechanism. This means they share the same memory and thread space as your application. For high-throughput scenarios, you need to be careful. First, event size matters. Large event payloads consume heap memory and increase serialization/deserialization cost. Keep events small—include only identifiers and essential data. If a listener needs more data, it should fetch it from a repository. Second, thread pool sizing. For async events, the thread pool must be sized based on the expected load and the latency of downstream calls. A common formula: pool size = (number of cores * 2) / (1 - blocking coefficient). For I/O-bound listeners (e.g., HTTP calls), use a larger pool. For CPU-bound listeners (e.g., complex calculations), use a smaller pool. Monitor the pool's queue depth. If it's consistently growing, you need more threads or a faster downstream. Third, backpressure. If your event publisher is faster than your listeners, the queue will fill up and eventually reject tasks. The default rejection policy is AbortPolicy which throws a TaskRejectedException. This is dangerous because the publisher's thread will get the exception, potentially rolling back the transaction. Use CallerRunsPolicy instead—it runs the task on the publisher's thread, which naturally slows down the publisher. Or use a bounded queue with a reasonable capacity and monitor it. Fourth, consider batching. If you have many small events (e.g., 1000 order placed events per second), processing them individually is inefficient. Batch them: collect events in a buffer and process them in bulk. Spring doesn't support this natively, but you can implement it with a scheduled task that drains a queue every few seconds. Fifth, for cross-service eventing, use a real message broker. Spring Events are great for intra-service communication. For inter-service, use Spring Cloud Stream with Kafka or RabbitMQ. Don't try to use Spring Events for distributed systems—it won't work across JVM boundaries.
The Phantom Charge: When Async Events Double-Billed Customers
- Never trust async event delivery without a persistent store for critical operations like payments
- Always implement idempotency keys in event payloads and check them before processing
- Monitor your async executor queue depth and set up alerts for rejections
- Use @TransactionalEventListener with proper phase only when you understand the transaction lifecycle
curl -X POST /actuator/beans | grep -i listenertail -f logs/app.log | grep 'EventListener'| File | Command / Code | Purpose |
|---|---|---|
| InvoiceCreatedEvent.java | public record InvoiceCreatedEvent( | Setting Up Spring Events |
| AsyncExceptionHandlerConfig.java | @Configuration | What the Official Docs Won't Tell You |
| AsyncInvoiceListener.java | @Component | Async Listeners |
| OrderDomainService.java | @Service | Domain Events |
| EventSourcedOrderRepository.java | @Repository | Event Sourcing |
| EventTest.java | @SpringBootTest | Testing Event-Driven Flows |
| DeadLetterQueueService.java | @Service | Production Patterns |
| BatchedEventProcessor.java | @Component | Performance Considerations and Scaling Event-Driven Systems |
Key takeaways
Interview Questions on This Topic
Explain how @TransactionalEventListener works and when you would use it over @EventListener.
Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Lessons pulled from things that broke in production.
That's Spring Boot. Mark it forged?
9 min read · try the examples if you haven't