Event-Driven Architecture
@EventListener, domain events, ApplicationEventPublisher
Event-Driven Architecture
Spring application events are powerful when you treat domain events as first-class concepts and remain honest about the limits of in-process delivery.
1. Definition
What is it?
Event-driven architecture models meaningful state changes as events and lets other parts of the system react to them. In Spring, the lightest-weight form uses ApplicationEventPublisher, @EventListener, and @TransactionalEventListener.
Why does it exist?
It exists to reduce direct coupling. A use case should not need to know every secondary action that may happen after an important business event. Publishing an event keeps the initiating code focused while allowing additional reactions to evolve independently.
Where does it fit?
Spring application events are primarily an in-process mechanism within a single application instance. They are excellent for internal decoupling, domain event propagation, audit triggers, and side-effect separation. They are not a drop-in replacement for Kafka or RabbitMQ when durability and cross-service delivery are required.
2. Core Concepts
2.1 Publisher and listeners
A publisher announces that something happened by calling publishEvent(...). One or more listeners consume the event.
Typical in-process event flow:
- The application service calls
publishEvent(OrderPlaced). - Spring dispatches the event to every matching listener.
- Each listener performs its own side effect independently.
2.2 Synchronous versus asynchronous listeners
By default, Spring event listeners run synchronously in the publisher thread. That means:
- listener latency affects the original request;
- thrown exceptions can propagate back to the publisher;
- transaction context may still be active.
When combined with @Async, a listener becomes decoupled from the caller thread, but you must then deal with executor sizing, failure visibility, and operational tracing.
2.3 Transaction-aware events
@TransactionalEventListener binds listener execution to a transaction phase.
| Phase | Meaning |
|---|---|
BEFORE_COMMIT |
before commit happens |
AFTER_COMMIT |
only after a successful commit |
AFTER_ROLLBACK |
after rollback |
AFTER_COMPLETION |
after transaction finishes either way |
This is one of the most important distinctions in production applications.
2.4 Domain events in DDD
A domain event captures something meaningful in the business domain, such as OrderPlaced, CustomerRegistered, or PaymentCaptured. It should describe a fact that occurred, not an implementation detail.
2.5 Event sourcing relationship
Event sourcing stores events as the source of truth and reconstructs state from them. That is far broader than simply using Spring application events. They share vocabulary, but not architectural scope.
2.6 Internal versus external events
- Internal event: lightweight, in-memory, process-local.
- External event: durable, transported through Kafka, RabbitMQ, or another broker.
3. Practical Usage
A typical example is user registration. Once a user is persisted, multiple secondary actions may follow: create defaults, emit audit data, start analytics tracking, or send a welcome email. If all of that logic lives in one service method, the core use case becomes bloated. Publishing UserRegisteredEvent keeps the central flow compact and extensible.
Another common use case is domain modeling inside a modular monolith. An order module can publish OrderPlacedEvent, and internal listeners in inventory, billing, or reporting modules can respond without the ordering module directly knowing each dependency.
@TransactionalEventListener(AFTER_COMMIT) is especially valuable when side effects must happen only if the database transaction truly succeeded. Sending an email, triggering an external webhook, or publishing to Kafka before commit completion can create business inconsistencies if the transaction later rolls back.
Asynchronous listeners are tempting, but they should be used deliberately. They improve responsiveness but reduce determinism. Once a listener runs in a background thread, failures are no longer naturally visible to the initiator, and retry strategy becomes an explicit concern.
4. Code Examples
4.1 Publish a domain event
public record UserRegisteredEvent(UUID userId, String email) {}
@Service
class RegistrationService {
private final UserRepository userRepository;
private final ApplicationEventPublisher eventPublisher;
RegistrationService(UserRepository userRepository,
ApplicationEventPublisher eventPublisher) {
this.userRepository = userRepository;
this.eventPublisher = eventPublisher;
}
@Transactional
public UUID register(RegisterUserCommand command) {
User user = userRepository.save(new User(command.email()));
eventPublisher.publishEvent(new UserRegisteredEvent(user.getId(), user.getEmail()));
return user.getId();
}
}
4.2 Standard and transactional listeners
@Component
class RegistrationEventHandlers {
@EventListener
public void initializeDefaults(UserRegisteredEvent event) {
System.out.println("Initializing defaults for " + event.userId());
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sendWelcomeEmail(UserRegisteredEvent event) {
System.out.println("Sending welcome email to " + event.email());
}
}
4.3 Asynchronous event listener
@Configuration
@EnableAsync
class AsyncEventsConfig {
@Bean
Executor eventExecutor() {
return Executors.newFixedThreadPool(4);
}
}
@Component
class AnalyticsEventHandler {
@Async
@EventListener
public void onUserRegistered(UserRegisteredEvent event) {
System.out.println("Tracking analytics for " + event.userId());
}
}
4.4 Domain events collected from an aggregate
class Order {
private final List<Object> domainEvents = new ArrayList<>();
void place() {
// state transition logic
domainEvents.add(new OrderPlacedEvent(id));
}
List<Object> domainEvents() {
return List.copyOf(domainEvents);
}
}
5. Trade-offs
Benefits
- reduces direct coupling inside the application;
- keeps use cases focused on primary business actions;
- allows new side effects to be added with limited modification;
- encourages domain-centric language.
Costs
- control flow becomes less explicit;
- synchronous listeners can slow or fail the initiating action;
- asynchronous listeners add concurrency and observability complexity;
- in-process delivery is not durable or shareable across services.
Use it when
- you need internal decoupling within one application;
- domain events express meaningful business facts;
- secondary actions should remain separated from the main use case.
Avoid it when
- durability and replay are mandatory;
- multiple services need the event as an integration contract;
- the team struggles to manage implicit flows safely.
6. Common Mistakes
- Confusing internal events with external messaging. Spring application events disappear with the process and are not an integration backbone.
- Forgetting default synchrony. Many latency and rollback surprises come from assuming listeners are asynchronous.
- Using
@EventListenerfor external side effects before commit. This can trigger emails or outbound messages for transactions that later fail. - Publishing noisy technical events. Events should represent meaningful facts, not every internal implementation step.
- Ignoring async error handling. Failures in background listeners need explicit logging, metrics, and retry strategy.
- Creating event loops. Listeners that publish new events carelessly can cause recursive chains.
7. Senior-level Insights
A crucial design principle is separating the meaning of an event from its transport. OrderPlaced can be a domain event first and an external integration event second. That separation prevents the domain model from being polluted by broker-specific concerns.
Another important insight is that event-driven code trades explicit dependencies for implicit behavioral coupling. This is powerful, but only if naming, ownership, and documentation remain strong. Otherwise, events become invisible jumps in the control flow and incident debugging becomes painful.
@TransactionalEventListener(AFTER_COMMIT) is often the minimum safe baseline for important side effects. If the event must survive crashes, restarts, or cross-service delivery, move beyond in-process listeners toward outbox-based publication and a real broker.
Event sourcing should not be adopted accidentally. Using application events does not mean you have an event-sourced system. Event sourcing changes persistence, replay, versioning, and operational tooling. Treat it as a deliberate architecture, not an annotation choice.
8. Glossary
- ApplicationEventPublisher: Spring component used to publish events.
@EventListener: method annotation for consuming events.@TransactionalEventListener: listener tied to transaction phases.- Domain event: business-significant fact that happened.
- In-process: executed within the same application instance.
- Async listener: listener executed on a different thread.
- Event sourcing: persisting events as the primary source of truth.
- Outbox pattern: reliable bridge from database transaction to external messaging.
9. Cheatsheet
| Topic | Rule of thumb | Guidance |
|---|---|---|
@EventListener |
synchronous by default | keep handlers fast |
@Async |
decouples caller thread | add executor, logging, metrics |
@TransactionalEventListener |
use for transaction-aware reactions | prefer AFTER_COMMIT for side effects |
| Internal event | simple and local | not durable |
| External event | broker-backed | better for service integration |
| Domain event | model business facts | avoid technical noise |
| Event sourcing | separate architecture | do not equate it with app events |
🎮 Games
8 questions