skip to content

How do you decide between staying with in-JVM application events and moving to a broker-backed message channel? What are the trade-offs?

level: seniorimportance: should knowfreq 42%

answer

  1. same JVM + no durability -> app events
  2. cross-process / durable / buffer / replay -> broker
  3. broker cost: ops, serialization, versioning, at-least-once
  4. idempotent consumers for at-least-once
  5. bridge: AFTER_COMMIT event -> broker + outbox

basics

~20 s

Use in-JVM events when producer and consumer live in the same app and you don't need durability. Move to a broker when the consumer is a separate process, or you need messages to survive crashes, buffer under load, retry, replay, or fan out to independent consumers.

solid answer

~50 s

In-JVM application events are cheap, synchronous, type-safe, need no infrastructure, and share the caller's transaction — great for decoupling modules inside one deployable. Their limits: they can't leave the process, aren't durable (a crash loses them), don't buffer, and don't survive redeploys. A broker-backed channel (Kafka/RabbitMQ via Spring Cloud Stream or JMS) adds serialization, cross-process delivery, durability, back-pressure, retry/DLQ, replay, and independent consumer lifecycles — at the cost of operational complexity, serialization/versioning concerns, network latency, and only eventual (at-least-once) delivery. Decision drivers: is the consumer in the same JVM? do you need the message to survive failure? do you need to decouple deployment/scaling? do you need buffering or replay? If any point beyond the JVM, cross the boundary — typically bridging in-JVM events to the broker via an AFTER_COMMIT listener plus an outbox for reliability.

code

java · 23 lines
java
// Hybrid: domain stays broker-agnostic; a bridge crosses the boundary.
@Service
class PaymentService {
    private final ApplicationEventPublisher events;
    PaymentService(ApplicationEventPublisher events) { this.events = events; }

    @Transactional
    void capture(String id) {
        // ...DB work...
        events.publishEvent(new PaymentCaptured(id)); // in-JVM, decoupled
    }
}

@Component
class PaymentBridge {
    private final StreamBridge stream;
    PaymentBridge(StreamBridge stream) { this.stream = stream; }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    void toBroker(PaymentCaptured e) {
        stream.send("payments-out-0", e); // now cross-process + durable-ish
    }
}

go deeper

for a junior

Know the one-line rule: same app = events, other app = broker.

for a middle

List a few concrete drivers (durability, buffering, separate process).

for a senior

Weigh trade-offs both ways and name the hybrid bridge pattern.

for a principal

Tie the choice to system evolution (Modulith -> services), delivery semantics, idempotency, and outbox-based reliability.

## The decision, framed Both mechanisms are pub/sub, so the choice is about **boundaries and guarantees**, not syntax. ### Stay with in-JVM application events when… - **Same deployable.** Producer and consumer are beans in the same app. - **No durability needed.** Losing the notification on a crash is acceptable, or the source of truth is the DB anyway. - **You want transaction participation.** A synchronous listener runs in the caller's transaction (all-or-nothing with the DB work). - **Simplicity.** Zero infrastructure, compile-time type safety, trivial testing. Weaknesses: cannot cross a process; not durable; no buffering; no built-in retry/replay; synchronous listeners can slow the caller; async ones lose transactional coupling. ### Move to a broker-backed channel when… - **Separate process/service** must consume it. Application events physically cannot leave the JVM. - **Durability / reliability.** The message must survive producer crashes and redeploys. - **Buffering & back-pressure.** Bursts should queue rather than overload the consumer. - **Retry / dead-letter / replay.** Brokers give redelivery, DLQs, and (Kafka) log replay from offsets. - **Independent lifecycles & scaling.** Consumers can be down, deployed, or scaled separately. - **Fan-out to many independent consumers**, each with its own progress. Costs: operational overhead (run/monitor the broker), **serialization and schema/versioning** of payloads, network latency, and weaker delivery semantics — typically **at-least-once**, so consumers must be **idempotent**; exactly-once is limited/expensive. ## Spring surfaces for each - In-JVM: `ApplicationEventPublisher`, `@EventListener`, `@TransactionalEventListener`. - Broker: `spring-messaging` (`Message`, `MessageChannel`) underneath **Spring Cloud Stream** (`StreamBridge`, functional `Supplier`/`Consumer`/`Function` binders), **spring-kafka** (`KafkaTemplate`), **spring-jms**, Spring Integration. ## The hybrid that most systems use Don't make the domain code talk to Kafka directly. Publish an **in-JVM event** from the domain, and bridge it to the broker in a `@TransactionalEventListener(AFTER_COMMIT)`. For guaranteed delivery, write the event to a **transactional outbox** table in the same DB transaction and let a relay/CDC ship it — this closes the dual-write gap that a bare AFTER_COMMIT send leaves open. ## Anti-patterns - Using a broker for purely in-process decoupling (needless ops complexity and latency). - Using application events to notify another service (impossible — silently no-op across processes). - Assuming broker delivery is exactly-once and skipping idempotency.

  • Your two modules are in the same Spring Modulith app today but may split into services later. What do you do now?
    Communicate via in-JVM application events now (simple, transactional), keeping payloads serializable and the contract explicit. When a module splits out, replace the in-JVM listener with a broker bridge — the event contract stays, only the transport changes.
  • Why must broker consumers usually be idempotent?
    Because brokers typically guarantee at-least-once delivery — a message can be redelivered after a consumer crash or ack failure — so processing the same message twice must not corrupt state.

saying these in an interview costs you the question

  • Reaching for Kafka to decouple two beans in the same JVM
  • Trying to notify another service with ApplicationEventPublisher
  • Assuming brokers give exactly-once so consumers need no idempotency
  • Ignoring payload serialization/versioning when crossing the boundary

context