skip to content

When would you choose Spring application events over a direct method call, and what are their limits?

level: principalimportance: should knowfreq 40%

answer

  1. decouple modules in one JVM
  2. fan-out one-to-many
  3. in-memory, no persistence/retry
  4. sync + coupled failure by default
  5. outbox / Modulith registry for durability

basics

~20 s

Use events to decouple modules in one JVM: the producer emits a fact and any number of consumers react, with no compile-time dependency. Limits: in-process only, not persisted or retried, synchronous by default, and no delivery guarantee across crashes.

solid answer

~50 s

Application events are an in-process publish/subscribe mechanism for decoupling within a single JVM. Prefer them when a producer must announce a fact without knowing or depending on consumers — the classic case is a modular monolith (Spring Modulith) where one module shouldn't import another; the event is the seam. They shine for fan-out (many reactions to one fact) and for keeping domain logic free of side-effect wiring. But know the limits: delivery is in-memory, not persisted, so a crash loses in-flight events; there's no retry or dead-lettering; it's synchronous by default so a slow/failing listener affects the publisher; ordering across listeners is only via @Order; and it's single-JVM, not cross-service messaging. For durability use @TransactionalEventListener plus an outbox, or Spring Modulith's event publication registry; for cross-process, use Kafka/RabbitMQ. Don't use events where you need a return value or strong coupling.

code

java · 23 lines
java
// KataJob-style seam: module A announces a fact; it does NOT depend on
// any consumer module. Consumers may be absent (intentional scaffolding).
@Service
class UserService {
    private final ApplicationEventPublisher events;
    UserService(ApplicationEventPublisher events) { this.events = events; }

    @Transactional
    public User register(String email) {
        User saved = /* persist */ new User(email);
        events.publishEvent(new UserRegistered(saved.id(), email));
        return saved; // caller gets the result via the direct call,
                      // side effects happen via listeners.
    }
}

// A durable reaction: only after commit, and (for guaranteed delivery)
// backed by an outbox / Spring Modulith event publication registry.
@Component
class AuditListener {
    @TransactionalEventListener
    void on(UserRegistered e) { /* record audit / notify */ }
}

go deeper

for a junior

Know events decouple producer from consumers within one app.

for a middle

Contrast with direct calls (return values, coupling) and note sync-by-default.

for a senior

Enumerate limits: no persistence/retry, coupled failure, weak ordering, single-JVM.

for a principal

Reach for outbox / Modulith registry for durability, brokers for cross-service, and guard against event-spaghetti.

## The design question Events trade an explicit, compile-time-visible call for a decoupled, runtime-resolved notification. That trade is worth it in specific situations and harmful in others. ## Choose events when - **Decoupling module boundaries in one process.** In a Spring Modulith app like KataJob, module A shouldn't depend on module B. A publishes a domain event; B (if present) subscribes. This keeps the dependency graph acyclic and lets you add/remove consumers without touching the producer. KataJob's `UserRegistered` is exactly this — published with *no* consumer yet, as intentional scaffolding. - **Fan-out / one-to-many.** One fact triggers several independent reactions (audit log, email, cache invalidation). Direct calls would force the producer to know and orchestrate all of them. - **Keeping core logic clean.** The use-case emits 'this happened'; peripheral side effects live in listeners, so the core isn't cluttered with wiring. - **Reacting to framework/context lifecycle** (e.g., `ContextRefreshedEvent`, `ApplicationReadyEvent`). ## Prefer a direct call when - You need a **return value** or the result to continue the flow. - The relationship is **essential and synchronous** — the caller genuinely depends on the callee's work completing and its failure. - There's exactly one collaborator and hiding the link behind an event only obscures control flow ("action at a distance"). ## The limits (say these unprompted) 1. **In-process only.** It's the ApplicationContext bus, not a message broker. No cross-service delivery. 2. **Not persisted, no retry, no dead-letter.** If the JVM dies with events in flight (or after commit but before an AFTER_COMMIT listener runs), they're lost. There's no built-in redelivery. 3. **Synchronous & coupled failure by default.** A listener's exception (sync) aborts remaining listeners and bubbles to the publisher; a slow listener slows publish. Async fixes throughput but loses context and error visibility. 4. **Weak ordering.** Only `@Order` gives determinism among sync listeners; async removes even that. 5. **Debuggability.** Control flow is implicit — harder to trace than a call graph; over-use creates 'spaghetti by events'. ## Getting durability / delivery guarantees - **@TransactionalEventListener** ensures side effects only run after commit — but still no crash durability. - **Transactional outbox pattern** or **Spring Modulith's Event Publication Registry** persists published events and completes them when listeners succeed, giving at-least-once semantics and recovery after restart. - **Cross-process:** move to Kafka/RabbitMQ; Spring events are not a substitute. ## A senior heuristic Use events for *notifications of facts across a decoupling seam*, especially in a modular monolith, and reach for persistence (outbox/registry) the moment 'the side effect must not be lost' becomes a requirement. Use direct calls for essential, synchronous, return-carrying collaboration. Don't let events become an untraceable implicit call graph.

  • The requirement becomes 'the welcome email must never be lost even if the server restarts.' What do you change?
    In-process events can't guarantee that. Persist the event via a transactional outbox or Spring Modulith's Event Publication Registry (at-least-once, replayed on restart), or hand off to a durable broker like Kafka/RabbitMQ.
  • When is publishing an event the wrong choice?
    When you need a return value, when there's one essential synchronous collaborator whose failure must abort the caller, or when hiding a direct dependency behind an event just obscures control flow.
  • Why are events a good fit for a Spring Modulith monolith specifically?
    They let one module notify others without a compile-time import, keeping module dependencies acyclic and enforceable while still allowing in-process reactions — the sanctioned inter-module comms besides public *Api calls.

saying these in an interview costs you the question

  • Treating in-process events as reliable cross-service messaging
  • Assuming events are persisted/retried like a broker
  • Using events where a direct return-carrying call is needed
  • Over-using events into an untraceable implicit call graph

context