Event-Driven Architecture with Spring Boot
Master Spring Boot event-driven architecture: ApplicationEvent, @EventListener, @TransactionalEventListener, async events, domain events, and when to use Kafka.
- Use @EventListener for in-process decoupling between Spring beans without adding message broker complexity
- Use @TransactionalEventListener(phase=AFTER_COMMIT) to guarantee events fire only after the DB transaction commits
- Annotate listeners with @Async to avoid blocking the publisher's thread — always configure a thread pool
- Spring Data domain events (AbstractAggregateRoot) let entities self-publish events on save
- Graduate to Kafka/RabbitMQ when you need cross-service communication, durability, or replay capability
Think of ApplicationEvent like an office announcement system: one person shouts a message over the intercom and every department that cares about it reacts on their own schedule. The announcer doesn't wait for each department to finish — they just broadcast and move on. When you need the announcement to reach other buildings (other services), that's when you upgrade from the intercom to a proper messaging system like Kafka.
It's 2 AM and your order service is sending confirmation emails inside the same database transaction that places the order. The SMTP server is slow, the transaction holds a database connection for 4 extra seconds, and under Black Friday load your connection pool exhausts in minutes. Your entire checkout flow grinds to a halt — not because the database is slow, but because you coupled email sending to a DB transaction.
Event-driven architecture is the cure. By publishing an OrderPlacedEvent after your business logic completes, you let the email service react asynchronously without holding a connection open. Spring Boot has first-class support for this pattern through its ApplicationEvent infrastructure — no extra dependencies, no message broker, zero network calls.
But the devil is in the details. A naive @EventListener fires inside the publisher's transaction. If the email listener throws before the outer transaction commits, you roll back a completed order. If you use @Async naively, the event fires before the DB row is visible to other threads. Getting event-driven architecture right in Spring Boot means understanding exactly when and how events fire.
This guide covers the full spectrum: in-process events for decoupling within a monolith, @TransactionalEventListener for safe post-commit side effects, domain events from Spring Data aggregates, and the decision framework for when your event bus needs to graduate to Kafka or RabbitMQ. Everything shown runs on Spring Boot 3.x and Java 17+, tested under real production load.
ApplicationEvent and @EventListener: The Foundation
Spring's event system predates annotation-driven programming — it's been in the framework since version 1.0. But modern Spring Boot 3.x makes it elegant. You define an event as a plain Java record or class, publish it via ApplicationEventPublisher, and annotate listener methods with @EventListener. No XML, no interface implementation required.
The critical thing to understand is execution context: by default, @EventListener executes synchronously in the publisher's thread. This means the listener shares the publisher's transaction, and any exception in the listener propagates back to the publisher. This is sometimes desired — if you're doing in-transaction validation — but often it's not what you want.
Spring also supports generic events. If you publish a GenericEvent<Order>, you can write listeners that are type-safe: @EventListener public void onOrderEvent(GenericEvent<Order> event). Spring resolves the generic parameter at runtime using reflection on the listener method signature.
Conditional listening is another powerful feature: @EventListener(condition = "#event.status == 'FAILED'") lets you filter events using Spring Expression Language without cluttering listener logic with if-statements. This keeps each listener focused on a single concern.
Ordering multiple listeners for the same event type uses @Order. Lower values run first. This matters when you have listeners that must run in sequence — for example, an audit log listener that must run before a notification listener that reads audit data.
@TransactionalEventListener: Safe Post-Commit Events
@TransactionalEventListener is the most underused and most important annotation in Spring's event system. It solves the classic problem: you want to send an email or call an external API after your database transaction commits, but you don't want to do it inside the transaction (holding connections open) and you don't want it to fire if the transaction rolls back.
The annotation has four phases: BEFORE_COMMIT, AFTER_COMMIT, AFTER_ROLLBACK, and AFTER_COMPLETION. AFTER_COMMIT is what you want 90% of the time — it guarantees the listener only fires once the transaction has successfully committed. If the transaction rolls back (due to an exception, timeout, or constraint violation), the listener never fires.
There's a critical gotcha: if the listener itself needs to write to the database, it needs its own transaction. Because AFTER_COMMIT runs after the outer transaction closes, any @Transactional annotation on the listener method must use REQUIRES_NEW propagation — otherwise Spring throws an IllegalStateException because there's no active transaction to join.
The fallbackExecution = true attribute controls what happens when the listener is called outside a transaction. By default, AFTER_COMMIT events are silently dropped if published without an active transaction. Setting fallbackExecution = true makes them execute immediately in that case. This is useful for integration tests where you might publish events without transactions.
One production pattern worth highlighting: combine @TransactionalEventListener with the outbox pattern. Write the event payload to an outbox table in the same transaction as your business data, then use the listener to trigger an async process that reads from the outbox and publishes to Kafka. This gives you exactly-once semantics even if the application crashes between DB commit and Kafka publish.
Domain Events with Spring Data Aggregates
Spring Data provides a more domain-driven approach to events through the AbstractAggregateRoot class. The idea is that your domain entity — the Aggregate Root in DDD terminology — accumulates events internally and they are published automatically when the repository saves the entity. This keeps event publishing logic inside the domain model rather than scattering publishEvent() calls throughout service classes.
The mechanism works through Spring Data's repository save() method. When you call orderRepository.save(order), Spring Data checks if the entity extends AbstractAggregateRoot, collects all registered domain events via domainEvents(), publishes them through ApplicationEventPublisher, and then calls clearDomainEvents(). This sequence means domain events are always consistent with the entity state — no way to forget to publish them.
This pattern is particularly valuable for complex aggregates that transition through multiple states. An Order might go through Created → PaymentPending → Confirmed → Shipped. Each transition calls registerEvent() on the entity, and the service layer just calls save() — no knowledge of which specific events to publish.
The tradeoff is that this coupling between repository.save() and event publishing can surprise developers who don't expect side effects from what looks like a simple persistence call. Always document this behavior prominently in your aggregate classes.
Combine this with @TransactionalEventListener for a complete domain-events pattern: entities register events, repository save publishes them, listeners react after commit. This is the closest Spring gets to a true event-sourcing lite pattern without full event sourcing infrastructure.
save() twice, events registered before the first save won't re-publish. Ensure all state transitions and registerEvent() calls happen before the single save() call.save().Async Events with @Async: Threading and Error Handling
Adding @Async to an @EventListener moves listener execution to a thread pool, decoupling the listener's lifecycle from the publisher's thread entirely. The publisher's method returns immediately after publishing, and the listener runs whenever a thread pool thread picks it up. This is essential for slow listeners — email sending, PDF generation, webhook calls — that shouldn't impact the publisher's response time.
But async events introduce complexity that trips up even experienced teams. First, error handling: exceptions in @Async methods don't propagate to the caller — they disappear into the void unless you configure an AsyncUncaughtExceptionHandler. Without this, a failing listener silently drops errors and you have no visibility into failures.
Second, transaction boundaries: @Async listeners run in a completely separate thread with no active transaction. If your async listener needs database access, it must start its own transaction (@Transactional on the listener method with default propagation REQUIRED will create a new one). This is usually what you want, but it means the async listener might see different data than the publisher saw if the publisher's transaction hasn't committed yet — which is why combining @Async with @TransactionalEventListener(AFTER_COMMIT) is the recommended pattern.
Third, thread pool sizing: without explicit configuration, Spring uses SimpleAsyncTaskExecutor — a new thread per task, no pooling, unbounded. Under load this will exhaust system thread limits. Always configure an explicit ThreadPoolTaskExecutor with bounded queue capacity and a RejectedExecutionHandler.
Fourth, thread local data: @Async runs in a different thread, so any ThreadLocal state from the publisher (security context, MDC logging context, request-scoped beans) is not automatically available. You need explicit context propagation — for security, configure DelegatingSecurityContextAsyncTaskExecutor; for MDC, use a custom TaskDecorator.
Internal Events vs. Kafka: The Decision Framework
The most expensive mistake teams make with event-driven architecture is either using Kafka for everything (massive operational overhead for simple use cases) or staying with in-process events too long (losing events on restarts, no cross-service communication). Here's the decision framework used by experienced platform teams.
Use Spring ApplicationEvent when: all consumers live in the same JVM, you're decoupling beans within a monolith, events don't need to survive application restarts, you need simple in-transaction coordination, or you want zero additional infrastructure. Good use cases: audit logging, cache invalidation, sending notifications from the same service, domain event publication within an aggregate.
Use Kafka when: consumers live in different services or different JVMs, you need event replay capability (new consumers can catch up from the beginning), you need guaranteed durability (events survive app crashes), you need high throughput (millions of events per second), you need consumer group load balancing, or you need event streaming and complex event processing. Good use cases: order events consumed by fulfillment, payment, analytics; user activity feeds; audit trails that must survive app restarts.
Use RabbitMQ when: you need request-reply patterns, you need priority queues, you need routing flexibility (topic/header exchanges), you need guaranteed delivery with acknowledgment but don't need replay, or your team is more familiar with AMQP semantics. Good use cases: task queues, work distribution, RPC patterns, integration with legacy systems.
The hybrid pattern: use Spring ApplicationEvents for in-process domain events, then have a dedicated infrastructure listener that picks up those events and publishes to Kafka/RabbitMQ for cross-service propagation. This keeps domain logic clean of broker concerns while still enabling distributed consumption. The outbox pattern makes this reliable: persist the event to an outbox table in the same transaction, then relay to Kafka asynchronously.
Testing Event-Driven Code
Testing event-driven code requires deliberate strategy because events decouple producer from consumer, which decouples test expectations from the action under test. Three layers of testing cover event-driven systems well: unit tests for event publishing, integration tests for listener behavior, and slice tests for the full event flow.
For unit tests, inject a ApplicationEventPublisher mock (or use ApplicationEvents from Spring Boot test utilities) to assert that the correct events are published with the right data. Don't test the listener in the same test — that's the unit boundary.
For integration tests of listeners, use @SpringBootTest with @RecordApplicationEvents to capture all published events, or directly invoke the listener method with a constructed event. For AFTER_COMMIT listeners, wrap the publisher call in a TransactionTemplate and commit it to trigger the listener.
For async listeners, use CountDownLatch or Awaitility to wait for async processing without sleeping fixed durations. Fixed sleep is the enemy of reliable test suites.
For end-to-end Kafka integration tests, use @EmbeddedKafka or Testcontainers with a real Kafka broker. The embedded approach is faster but Testcontainers gives you production-identical behavior. Always set specific group IDs in tests to prevent topic offset pollution between test runs.
The Double-Charge Bug: Events Inside Transactions
- Never publish side-effecting events inside a transaction unless you're using AFTER_COMMIT phase.
- Transactional retries will republish events, and downstream systems may not be idempotent by default.
- Always derive idempotency keys from stable business identifiers, not generated UUIDs.
new() instantiation means Spring won't proxy it. Verify the listener method signature matches the event type exactly, including generics. Enable DEBUG logging for org.springframework.context.event to see event dispatch. If using @Async, confirm @EnableAsync is on a configuration class and the TaskExecutor bean is properly configured.grep -r '@EventListener\|@TransactionalEventListener' src/main/java/ --include='*.java' -lcurl -s http://localhost:8080/actuator/beans | jq '.contexts[].beans | to_entries[] | select(.value.type | contains("Listener"))'Key takeaways
save() and events publish automaticallyCommon mistakes to avoid
6 patternsPublishing events inside @Transactional without using AFTER_COMMIT
Not configuring a custom TaskExecutor for @Async listeners
No AsyncUncaughtExceptionHandler configured
AsyncConfigurer.getAsyncUncaughtExceptionHandler() to log and alert on all uncaught async exceptionsUsing @Transactional without REQUIRES_NEW on an AFTER_COMMIT listener
Losing ThreadLocal context (MDC, SecurityContext) in async listeners
Using ApplicationEvents for cross-service communication
Interview Questions on This Topic
What is the difference between @EventListener and @TransactionalEventListener?
Frequently Asked Questions
That's Messaging. Mark it forged?
8 min read · try the examples if you haven't