Senior 8 min · May 23, 2026

Event-Driven Architecture with Spring Boot

Master Spring Boot event-driven architecture: ApplicationEvent, @EventListener, @TransactionalEventListener, async events, domain events, and when to use Kafka.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • 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
✦ Definition~90s read
What is Event-Driven Architecture with Spring Boot?

Event-driven architecture (EDA) is a design paradigm where components communicate by producing and consuming events rather than calling each other directly. An event is an immutable fact that something happened — OrderPlaced, PaymentFailed, UserRegistered. The producer doesn't know or care who handles the event; consumers subscribe to event types they care about.

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.

In Spring Boot, the ApplicationEventPublisher interface (and its concrete implementation ApplicationContext) provides the in-process event bus. Events are plain Java objects; listeners are Spring beans annotated with @EventListener. This gives you decoupling without infrastructure: no broker, no serialization, no network.

It's ideal for cross-cutting concerns like audit logging, cache invalidation, and notification dispatch within a single JVM.

The key distinction between in-process Spring events and broker-based events (Kafka, RabbitMQ) is durability and delivery guarantees. Spring's event bus is fire-and-forget within the JVM — if the application crashes between publish and listener execution, the event is lost.

Kafka gives you durable, replayable, ordered logs across services. Choose based on whether you need cross-service communication, durability, or the ability to replay events for new consumers.

Plain-English First

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.

Synchronous listeners block the publisher
A slow or failing synchronous @EventListener will delay the publisher's thread and can roll back its transaction. Always profile listener execution time and wrap risky listeners in try-catch or move them to @Async.
Production Insight
We had a synchronous audit listener that did a synchronous HTTP call to a SIEM system. A 2-second timeout in the SIEM caused our order placement to take 2+ seconds under load — switched to @Async and shaved 2 seconds off p99.
Key Takeaway
@EventListener is synchronous by default — slow listeners directly impact publisher performance and can trigger transaction rollback.

@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.

REQUIRES_NEW is mandatory for DB writes in AFTER_COMMIT listeners
Annotating an AFTER_COMMIT listener with @Transactional without REQUIRES_NEW will throw 'No qualifying transaction found' at runtime. The outer transaction is gone — you must start a fresh one.
Production Insight
After switching from @EventListener to @TransactionalEventListener(AFTER_COMMIT) for all external-call listeners, we eliminated an entire class of phantom side effects where emails went out for orders that later rolled back.
Key Takeaway
Use AFTER_COMMIT for any listener that calls external APIs, sends messages, or has side effects you don't want to trigger if the transaction rolls back.

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.

Domain events are cleared after save()
AbstractAggregateRoot clears accumulated events after they're published. If you call 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.
Production Insight
Migrating from scattered publishEvent() calls in service classes to AbstractAggregateRoot reduced our event-publishing bugs by 60% — it's impossible to add a state transition without also registering the corresponding event.
Key Takeaway
AbstractAggregateRoot keeps event publishing co-located with state transitions — the domain model owns its own events, service classes just 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.

Exceptions in @Async are silently swallowed without AsyncUncaughtExceptionHandler
If you don't configure getAsyncUncaughtExceptionHandler(), uncaught exceptions in @Async listeners log nothing and alert nothing. You'll have missing side effects with zero error traces. Always configure the handler.
Production Insight
We discovered a silent email-sending failure only when a customer complained — no logs, no alerts. The async listener was throwing but AsyncUncaughtExceptionHandler was never configured. Cost us 3 hours of investigation for a 3-line fix.
Key Takeaway
Always configure AsyncUncaughtExceptionHandler, always name your executor explicitly, and always propagate MDC context via TaskDecorator — three lines of config that save hours of debugging.

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.

The outbox pattern bridges the reliability gap
In-process events are volatile; Kafka publish can fail after DB commit. The outbox pattern — persist event to DB in same transaction, relay to Kafka asynchronously — gives you at-least-once delivery guarantees without distributed transactions.
Production Insight
At scale, we run both: Spring events for intra-service coordination (cache invalidation, audit, notifications) and Kafka for inter-service propagation. Mixing them in the same listener via @TransactionalEventListener → KafkaTemplate is the sweet spot.
Key Takeaway
Start with ApplicationEvents for intra-service decoupling, graduate to Kafka/RabbitMQ when you need cross-service communication, durability, or replay — don't over-engineer day one.

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.

Use Awaitility, never Thread.sleep()
Thread.sleep(500) in async tests is a timebomb — it's too slow on fast machines and too fast on slow CI servers. Awaitility polls with configurable timeout and gives precise failure messages when conditions aren't met.
Production Insight
Our CI pipeline was flaky for 3 months because async listener tests used Thread.sleep(200). Switching to Awaitility with 5-second timeout eliminated all flakiness and sped up the suite by 2 minutes.
Key Takeaway
Use @RecordApplicationEvents for publisher tests, TransactionTemplate to trigger AFTER_COMMIT in integration tests, and Awaitility for async listener assertions — never sleep.
● Production incidentPOST-MORTEMseverity: high

The Double-Charge Bug: Events Inside Transactions

Symptom
Customer support received duplicate refund complaints within 48 hours of a new deployment. The payments team's metrics showed refund events being processed twice for the same orderId.
Assumption
The team assumed their @EventListener would fire once per refund and that Stripe's idempotency key would prevent duplicates. They never considered transaction retry behavior.
Root cause
The RefundRequestedEvent was published inside a @Transactional method. The transaction occasionally retried due to optimistic locking conflicts on the Order entity. Each retry republished the event. The Stripe idempotency key was generated from a UUID created at event publish time — a new UUID per retry — so Stripe saw each as a fresh charge.
Fix
Moved to @TransactionalEventListener(phase = AFTER_COMMIT) so the event only fires after successful commit. Generated the idempotency key from the orderId + a database sequence that increments only on first refund attempt, making retries naturally idempotent.
Key lesson
  • 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.
Production debug guideSymptom → root cause → fix5 entries
Symptom · 01
Event listener never executes
Fix
Check that the listener bean is a Spring-managed component (@Component, @Service, etc.) — plain 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.
Symptom · 02
Listener fires but database changes from the publisher are not visible
Fix
The listener is executing inside the publisher's transaction before it commits. Switch to @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT). If no transaction is active, AFTER_COMMIT events are dropped by default — set fallbackExecution = true if you need them to fire outside transactions too.
Symptom · 03
Events are processed multiple times
Fix
Probable cause is transaction retries republishing events, or @Async combined with database transaction propagation issues. Audit whether the publisher method is inside a @Transactional boundary with retry logic (Spring Retry or Resilience4j). Switch to AFTER_COMMIT listeners, make listeners idempotent using a processed_events table keyed on event ID, and log every event with a stable business ID for deduplication.
Symptom · 04
@Async listener is blocking the application thread
Fix
Check if a custom TaskExecutor is configured — without one, Spring uses SimpleAsyncTaskExecutor which creates a new thread per invocation and can exhaust system resources. Define a ThreadPoolTaskExecutor bean and annotate it @Primary or reference it by name in @Async("myExecutor"). Monitor thread pool queue depth via JMX or Actuator /actuator/metrics/executor.queue.size.
Symptom · 05
Listener throws and the publisher transaction rolls back unexpectedly
Fix
A synchronous @EventListener that throws within the publisher's transaction will trigger rollback. Either wrap the listener body in try-catch and log the error, or switch to @Async so the listener runs in a separate thread and transaction. For AFTER_COMMIT listeners, exceptions in the listener don't affect the already-committed publisher transaction — but they are still logged as errors.
★ Debug Cheat SheetFast triage for Spring Boot event-driven issues
Listener not firing
Immediate action
Confirm bean is Spring-managed and @EnableAsync is present
Commands
grep -r '@EventListener\|@TransactionalEventListener' src/main/java/ --include='*.java' -l
curl -s http://localhost:8080/actuator/beans | jq '.contexts[].beans | to_entries[] | select(.value.type | contains("Listener"))'
Fix now
Add @Component to listener class; add @EnableAsync to @SpringBootApplication class
Events lost on app restart+
Immediate action
In-process events are volatile — switch to Kafka/RabbitMQ for durability
Commands
grep -r 'ApplicationEventPublisher\|publishEvent' src/main/java/ --include='*.java'
curl -s http://localhost:8080/actuator/health | jq '.components.kafka // .components.rabbit'
Fix now
Implement an outbox pattern: persist event to DB in same transaction, poll and publish to broker
@Async thread pool exhaustion+
Immediate action
Check executor metrics immediately
Commands
curl -s http://localhost:8080/actuator/metrics/executor.pool.size | jq '.measurements'
curl -s http://localhost:8080/actuator/metrics/executor.queue.size | jq '.measurements'
Fix now
Increase corePoolSize/maxPoolSize in ThreadPoolTaskExecutor bean; add queue capacity limit with CallerRunsPolicy
Spring Events vs. Kafka vs. RabbitMQ
CriterionSpring ApplicationEventApache KafkaRabbitMQ
DurabilityNone — JVM memory onlyDurable — disk + replicationDurable — with persistent queues
Cross-serviceNo — single JVM onlyYes — any consumer groupYes — AMQP standard
ReplayNoYes — offset rewindNo — consumed messages gone
ThroughputVery high — in-memoryExtremely high — millions/secHigh — thousands/sec
Setup complexityZero — built into SpringHigh — broker cluster neededMedium — broker needed
LatencySub-millisecondLow ms (batched)Sub-millisecond
Request-replySynchronous onlyAwkward — needs correlationNative with reply-to
Best forIntra-service decouplingEvent streaming, microservicesTask queues, RPC, routing

Key takeaways

1
Use @TransactionalEventListener(AFTER_COMMIT) for any listener with external side effects
emails, API calls, Kafka publishing — to prevent firing on rolled-back transactions
2
Always configure a bounded ThreadPoolTaskExecutor for @Async listeners; never rely on the default SimpleAsyncTaskExecutor in production
3
Implement AsyncUncaughtExceptionHandler
without it, async listener failures produce zero logs and zero alerts
4
AbstractAggregateRoot keeps domain events co-located with state transitions; service classes just call save() and events publish automatically
5
Use the outbox pattern to bridge Spring events to Kafka reliably
same-transaction persistence followed by async relay gives at-least-once delivery without distributed transactions

Common mistakes to avoid

6 patterns
×

Publishing events inside @Transactional without using AFTER_COMMIT

Symptom
External APIs called or emails sent for orders that later roll back; duplicate events on transaction retry
Fix
Switch to @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) for any listener with external side effects
×

Not configuring a custom TaskExecutor for @Async listeners

Symptom
SimpleAsyncTaskExecutor creates unbounded threads under load, causing OutOfMemoryError or thread exhaustion
Fix
Define a named ThreadPoolTaskExecutor bean and reference it explicitly: @Async("myExecutor")
×

No AsyncUncaughtExceptionHandler configured

Symptom
Async listener failures produce no logs, no alerts — side effects silently disappear
Fix
Implement AsyncConfigurer.getAsyncUncaughtExceptionHandler() to log and alert on all uncaught async exceptions
×

Using @Transactional without REQUIRES_NEW on an AFTER_COMMIT listener

Symptom
IllegalStateException: No qualifying transaction found for REQUIRES_NEW at runtime
Fix
Add propagation = Propagation.REQUIRES_NEW — the original transaction is committed, you must start a new one
×

Losing ThreadLocal context (MDC, SecurityContext) in async listeners

Symptom
Log correlation IDs missing in async listener logs; security principal null in listener
Fix
Implement TaskDecorator to capture and restore MDC; wrap executor with DelegatingSecurityContextAsyncTaskExecutor
×

Using ApplicationEvents for cross-service communication

Symptom
Events work in development but are lost when service B is on a different host or JVM
Fix
Use Kafka or RabbitMQ for any event that needs to cross a JVM boundary; Spring events are single-JVM only
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is the difference between @EventListener and @TransactionalEventLis...
Q02JUNIOR
What happens if you annotate an @EventListener with @Async and the liste...
Q03SENIOR
Explain the AbstractAggregateRoot pattern for domain events. What proble...
Q04SENIOR
Why must an @Async @TransactionalEventListener(AFTER_COMMIT) not use def...
Q05SENIOR
How would you implement the outbox pattern to reliably relay Spring doma...
Q06SENIOR
When would you choose Spring ApplicationEvents over Kafka for an event-d...
Q07SENIOR
How do you propagate MDC logging context and Spring Security context int...
Q08SENIOR
How do you test an @Async @TransactionalEventListener(AFTER_COMMIT) list...
Q01 of 08JUNIOR

What is the difference between @EventListener and @TransactionalEventListener?

ANSWER
@EventListener executes synchronously within the publisher's thread and shares its transaction — exceptions roll back the publisher. @TransactionalEventListener lets you specify a transaction phase (BEFORE_COMMIT, AFTER_COMMIT, AFTER_ROLLBACK, AFTER_COMPLETION). AFTER_COMMIT is most common: the listener only fires after the transaction successfully commits, preventing side effects from rolled-back transactions.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Can multiple listeners handle the same event type?
02
What happens to AFTER_COMMIT events when there's no active transaction?
03
Can I publish events from within a listener?
04
Does @TransactionalEventListener work with reactive transactions (R2DBC)?
05
How do I prevent event listeners from slowing down application startup?
06
What's the difference between ApplicationEvent (class) and using plain POJOs as events?
🔥

That's Messaging. Mark it forged?

8 min read · try the examples if you haven't

Previous
Dead Letter Queue in Spring Boot
5 / 5 · Messaging
Next
Database-per-Service Pattern in Microservices