What does @Externalized do in Spring Modulith, and how does externalizing events to a broker relate to the Event Publication Registry?
answer
- @Externalized = also send event to a broker
- value 'target::key', key is SpEL over the event
- add spring-modulith-events-kafka/-amqp/-jms
- routed through the registry → durable at-least-once outbox across the wire
- solves DB-commit-plus-broker-publish dual-write
basics
~20 s@Externalized marks an application event to also be published to an external broker (Kafka, RabbitMQ, etc.). Its value 'target::key' sets the destination and a SpEL routing key. The same Event Publication Registry tracks externalization, giving durable at-least-once delivery to the broker.
solid answer
~50 s@Externalized turns an in-process domain event into one that Spring Modulith also forwards to a message broker, so consumers can be other services rather than in-JVM listeners. You add a broker integration (spring-modulith-events-kafka, -amqp, -jms, and cloud variants) and annotate the event or configure EventExternalizationConfiguration to select which events go out. The annotation value uses a 'target::key' syntax — target is the destination (e.g. Kafka topic), and after :: an optional SpEL expression evaluated against the event produces the routing/partition key, e.g. @Externalized("orders::#{orderId()}"). Crucially, externalization is wired through the same Event Publication Registry: the outbound publication is persisted transactionally and only completed once the broker send succeeds, so a broker outage leaves an incomplete publication that gets replayed — durable at-least-once, the transactional outbox extended across the process boundary. This is the migration seam that lets a modular monolith evolve toward services without rewriting publishers.
go deeper
Know @Externalized sends an application event out to a broker like Kafka.
Explain the target::key syntax and that a broker integration dependency is required.
Explain that externalization goes through the registry for durable at-least-once and solves the dual-write problem.
Treat it as the outbox-across-the-wire migration seam: reason about schema/versioning of the wire contract, ordering per key, consumer idempotency, and gradual monolith-to-services extraction.
## The idea So far events are **in-process**: publisher and `@ApplicationModuleListener` live in the same JVM. **`@Externalized`** (from `org.springframework.modulith.events`) says: *this event should also leave the process and be published to a message broker*, so external systems/services can consume it. This is the bridge from 'modular monolith' toward 'event-driven services' — the same publisher code, now heard outside. ## Enabling it Add a broker integration module, e.g.: - `spring-modulith-events-kafka` - `spring-modulith-events-amqp` (RabbitMQ) - `spring-modulith-events-jms` - cloud variants (AWS SQS/SNS/Kinesis, etc.) With an integration on the classpath, Modulith's externalization support activates and looks for events to externalize. ## Selecting what gets externalized Two ways: 1. **Annotation:** put `@Externalized` on the event type. 2. **Programmatic:** define an `EventExternalizationConfiguration` bean to select events (by package/type), route them, and map/transform the payload — useful when you don't want broker annotations polluting domain events. ## The `target::key` routing syntax The `@Externalized` value is a string of the form **`target::key`**: - **target** — the destination name. For Kafka that's the **topic**; for AMQP an exchange/routing target, etc. - **`::key`** — optional. Everything after `::` is a **SpEL expression evaluated against the event instance**, producing the **routing/message key** (e.g. Kafka message key used for partitioning/ordering). ```java @Externalized("orders::#{#this.orderId()}") public record OrderCompleted(String orderId, long total) {} ``` Here every `OrderCompleted` is sent to the `orders` topic with the message key set to the order id, so all events for one order land on the same partition (per-key ordering). With no `::key`, only the target is used and the broker assigns partitioning. ## Relationship to the Event Publication Registry — the key point Externalization is **not** fire-and-forget over the network. It's wired through the **same Event Publication Registry**: 1. When the transaction commits, an **incomplete publication** is recorded for the externalization 'listener'. 2. Modulith attempts the **broker send**. 3. On a successful send, the publication is **completed**. 4. If the broker is **down** or the send fails, the publication stays **incomplete** and is subject to the normal recovery paths (republish-on-restart, `IncompleteEventPublications` resubmit). This is the **transactional outbox extended across the process boundary**: the event is durably stored with the business data in one transaction, then relayed to the broker with **at-least-once** semantics. It solves the classic dual-write problem (DB commit + broker publish can't be atomic) — you never publish to the broker without having committed, and you never permanently lose a publish because the broker was briefly unavailable. ## Consequences to reason about (principal level) - **At-least-once to the broker** → downstream consumers must dedupe/idempotent-consume; the routing key helps with per-key ordering but not exactly-once. - **Payload contract & schema evolution** become an inter-service concern now, not just internal — version your externalized event schemas carefully (consumers you don't control). - **Serialization/mapping:** you may externalize a different DTO than the internal event via the externalization configuration, decoupling internal model from wire contract. - **Ordering:** only guaranteed per key/partition, and replays can reorder — design consumers accordingly. - **Migration strategy:** you can start with in-process `@ApplicationModuleListener`s and later add `@Externalized` to the same events, gradually extracting a module into its own service — publishers don't change. ## When to use Reach for `@Externalized` when a consumer must live outside the JVM (another service, an analytics pipeline, a different team's system), or when you're deliberately carving a module toward independent deployment — while keeping the durability guarantees the registry already gives you internally.
- How does externalization avoid the classic dual-write (DB + broker) problem?It uses the Event Publication Registry as a transactional outbox: the outbound event is persisted in the same transaction as the business data, then relayed to the broker afterward. The DB commit and the broker send are never a single atomic act, but the durable outbox row guarantees the send is retried until it succeeds — at-least-once, no lost publish, no publish without commit.
- What is the part after :: in @Externalized("orders::#{orderId()}")?A SpEL expression evaluated against the event instance that produces the routing/message key (e.g. the Kafka message key), which controls partitioning and thus per-key ordering. Omit it to send to just the target and let the broker decide partitioning.
- If a consumer is another service, why must it still be idempotent?Because externalization is at-least-once — a broker send may be retried after a failure or after a replay of an incomplete publication, so the same event can arrive more than once. Consumers dedupe on a key or make processing naturally repeatable.
saying these in an interview costs you the question
- Thinking @Externalized replaces the broker's own producer config or bypasses the registry
- Assuming externalized delivery is exactly-once
- Believing DB commit and broker publish are made atomic (they're outbox-relayed, not 2PC)
- Ignoring that externalized event payloads are now an inter-service contract needing versioning