Event-Driven Architecture with Spring Boot
Master Spring Boot event-driven architecture: ApplicationEvent, @EventListener, @TransactionalEventListener, async events, domain events, and when to use Kafka..
20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- 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
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.
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.
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.
Why Extending ApplicationEvent Is Dead Code
Before Spring 4.2, your event class had to extend ApplicationEvent. That requirement is gone. Yet I still see teams cargo-culting it in 2023. Stop. Extending ApplicationEvent ties your domain object to Spring's infrastructure. That breaks if you ever need to serialize the event to Kafka or store it in a database. Instead, publish any POJO. Spring wraps it in a PayloadApplicationEvent transparently. Your listener receives the raw POJO. Clean separation. Testable without a Spring context. The only reason to extend ApplicationEvent is if you need the timestamp or source fields from the base class. If you need a timestamp, add your own Instant field. If you need the source, pass it as a constructor argument. Don't inherit infrastructure concerns into your domain model.
// io.thecodeforge — java tutorial // Right: pure POJO, no Spring dependency public record OrderPlacedEvent(String orderId, LocalDateTime occurredAt) {} // Wrong: unnecessary framework coupling // public class OrderPlacedEvent extends ApplicationEvent { // private final String orderId; // public OrderPlacedEvent(Object source, String orderId) { // super(source); // this.orderId = orderId; // } // }
The Event Multicaster: Your Threading Escape Hatch
Default event publishing is synchronous. Publisher thread blocks until all listeners finish. That's fine for in-process state changes. But when you need async behavior without @Async's proxy limitations, reach for SimpleApplicationEventMulticaster. It gives you a TaskExecutor per multicaster. You configure one in a @Bean method. Every event published to that context gets dispatched on the executor's threads. No @Async annotation, no proxy issues, no AOP surprises. You can also wrap listener execution in error handlers using setErrorHandler(). The huge win: you decouple event publication from listener execution without changing a single listener class. Any @EventListener annotated bean automatically becomes async. The downside: you lose the publisher's transaction context. Listeners run in their own transactions. Pair this with @TransactionalEventListener(phase = AFTER_COMMIT) when you need ordering guarantees, and use the raw multicaster for fire-and-forget notifications like audit logs or cache evictions.
// io.thecodeforge — java tutorial @Configuration public class AsyncEventConfig { @Bean ApplicationEventMulticaster applicationEventMulticaster() { var multicaster = new SimpleApplicationEventMulticaster(); multicaster.setTaskExecutor(Executors.newVirtualThreadPerTaskExecutor()); multicaster.setErrorHandler(throwable -> log.error("Event listener failed: {}", throwable.getMessage()) ); return multicaster; } }
Generics Support: Stop Casting, Start Typing
Spring 4.2 added generic event resolution. If your event implements an interface like Event<T>, Spring resolves the concrete type at runtime. This matters when you have an event like EntityCreatedEvent<T>. Publish new EntityCreatedEvent<>(order). Listeners can subscribe to EntityCreatedEvent<Order> and receive only order creation events. Spring uses ResolvableType to match the generic parameter. It's not magic — it's reflection under the hood, but it's production-tested. The gotcha: Spring caches listener subscriptions at startup. If you create a raw event with EntityCreatedEvent(null), the type resolution fails and your listener never fires. Always parameterize the event with a concrete class. This pattern lets you build generic domain event abstractions. One listener handles all entity creation events. Another handles only payment events. No if-else chains. No instanceof checks. Clean dispatch by type.
// io.thecodeforge — java tutorial public record EntityCreatedEvent<T>(T entity, Instant occurredAt) {} @Component class OrderCreationNotifier { @EventListener void onOrderCreated(EntityCreatedEvent<Order> event) { // Only fires for Order creations, not User creations notificationService.sendOrderConfirmation(event.entity()); } } // Publisher: applicationEventPublisher.publishEvent( new EntityCreatedEvent<>(order, Instant.now()) );
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"))'grep -r 'ApplicationEventPublisher\|publishEvent' src/main/java/ --include='*.java'curl -s http://localhost:8080/actuator/health | jq '.components.kafka // .components.rabbit'curl -s http://localhost:8080/actuator/metrics/executor.pool.size | jq '.measurements'curl -s http://localhost:8080/actuator/metrics/executor.queue.size | jq '.measurements'| Criterion | Spring ApplicationEvent | Apache Kafka | RabbitMQ |
|---|---|---|---|
| Durability | None — JVM memory only | Durable — disk + replication | Durable — with persistent queues |
| Cross-service | No — single JVM only | Yes — any consumer group | Yes — AMQP standard |
| Replay | No | Yes — offset rewind | No — consumed messages gone |
| Throughput | Very high — in-memory | Extremely high — millions/sec | High — thousands/sec |
| Setup complexity | Zero — built into Spring | High — broker cluster needed | Medium — broker needed |
| Latency | Sub-millisecond | Low ms (batched) | Sub-millisecond |
| Request-reply | Synchronous only | Awkward — needs correlation | Native with reply-to |
| Best for | Intra-service decoupling | Event streaming, microservices | Task queues, RPC, routing |
| File | Command / Code | Purpose |
|---|---|---|
| OrderPlacedEvent.java | public record OrderPlacedEvent(String orderId, LocalDateTime occurredAt) {} | Why Extending ApplicationEvent Is Dead Code |
| AsyncEventConfig.java | @Configuration | The Event Multicaster |
| GenericEventHandler.java | public record EntityCreatedEvent | Generics Support |
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?
What happens if you annotate an @EventListener with @Async and the listener throws an exception?
Explain the AbstractAggregateRoot pattern for domain events. What problem does it solve?
save(), it automatically publishes all registered events through ApplicationEventPublisher and clears them. This solves the problem of scattered publishEvent() calls in service classes — the entity owns its events, and you can't add a state transition without registering the corresponding event. It enforces the DDD principle that aggregates are the source of truth for domain events.Why must an @Async @TransactionalEventListener(AFTER_COMMIT) not use default @Transactional propagation if it needs to write to the database?
How would you implement the outbox pattern to reliably relay Spring domain events to Kafka?
When would you choose Spring ApplicationEvents over Kafka for an event-driven design?
How do you propagate MDC logging context and Spring Security context into @Async event listeners?
MDC.getCopyOfContextMap() before the task runs and restore it in the new thread, clearing it in the finally block. For Spring Security context, wrap the executor with DelegatingSecurityContextAsyncTaskExecutor — it copies the SecurityContext into the new thread. Both should be configured on your named TaskExecutor bean.How do you test an @Async @TransactionalEventListener(AFTER_COMMIT) listener in a Spring Boot integration test?
Thread.sleep(). For the listener's external calls, use @MockBean and verify with Mockito. The key insight: @Transactional on tests rolls back, so AFTER_COMMIT listeners never fire — you must use TransactionTemplate.execute() with an explicit commit.Frequently Asked Questions
Yes. Spring multicasts the event to all registered listeners for that type. Use @Order to control execution sequence for synchronous listeners. For async listeners, execution order is non-deterministic — design listeners to be independent.
By default, they are silently dropped. Set fallbackExecution = true on @TransactionalEventListener to make them execute immediately when no transaction is active. This is useful for integration tests or code paths that don't use transactions.
Yes, but be careful about infinite loops. Publishing a new event from a listener that itself triggers the same listener type will recurse infinitely. Use distinct event types for each step in an event chain, and consider whether the chain is better expressed as a saga with explicit state.
No — @TransactionalEventListener is designed for imperative (JDBC) transactions. For reactive Spring, use ReactiveTransactionSynchronization or publish events after the reactive transaction chain completes using .doOnSuccess() / .then().
By default, event listeners are registered during context refresh. If a listener initializes heavy resources (connection pools, caches), lazy-load them with @Lazy or initialize in @PostConstruct rather than at field injection time. Use @Async so startup-time events don't block context initialization.
Since Spring 4.2, any object can be an event — ApplicationEvent inheritance is no longer required. Plain Java records or classes work perfectly. ApplicationEvent adds a timestamp and source reference which you rarely need. Prefer plain records for immutability and brevity.
20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.
That's Messaging. Mark it forged?
8 min read · try the examples if you haven't