How do you decide between staying with in-JVM application events and moving to a broker-backed message channel? What are the trade-offs?
answer
- same JVM + no durability -> app events
- cross-process / durable / buffer / replay -> broker
- broker cost: ops, serialization, versioning, at-least-once
- idempotent consumers for at-least-once
- bridge: AFTER_COMMIT event -> broker + outbox
basics
~20 sUse 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 sIn-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// 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
Know the one-line rule: same app = events, other app = broker.
List a few concrete drivers (durability, buffering, separate process).
Weigh trade-offs both ways and name the hybrid bridge pattern.
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