Intermediate Reading time: ~7 min

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:

  1. The application service calls publishEvent(OrderPlaced).
  2. Spring dispatches the event to every matching listener.
  3. 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

  1. Confusing internal events with external messaging. Spring application events disappear with the process and are not an integration backbone.
  2. Forgetting default synchrony. Many latency and rollback surprises come from assuming listeners are asynchronous.
  3. Using @EventListener for external side effects before commit. This can trigger emails or outbound messages for transactions that later fail.
  4. Publishing noisy technical events. Events should represent meaningful facts, not every internal implementation step.
  5. Ignoring async error handling. Failures in background listeners need explicit logging, metrics, and retry strategy.
  6. 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