Spring Modulith Integration
Spring Modulith is an optional pairing, not a dependency.
Spring DDD publishes domain events through Spring’s ApplicationEventPublisher — JPA aggregates on repository save(); the event-sourced path after a successful append — and works perfectly without Modulith.
When Spring Modulith is on the classpath its Event Publication Registry takes over the delivery lifecycle. The registry records a publication per transactional listener within the originating business transaction, then drives after-commit dispatch: each listener is invoked once the transaction commits, and any listener that has not completed cleanly stays in Modulith’s failure lane to be retried rather than lost. This turns plain at-most-once Spring events into durable at-least-once delivery — and it is entirely a Modulith feature; Spring DDD’s events flow through it transparently.
Spring DDD adds a bridge on top, in the spring-ddd-starter-domain-events-modulith-jdbc starter: it connects Modulith’s Event Publication Registry to the message metadata attached to each event at publish time, so that metadata survives post-restart resubmission.
At-least-once delivery
Modulith’s registry stores one publication per (event × listener) pair in the same database transaction that published the event. After commit, each registered listener is invoked; a successful completion removes or stamps the publication (depending on the configured retention). A listener that throws, or a process that crashes mid-dispatch, leaves the publication incomplete; Modulith’s resubmitter picks it up on the next opportunity and retries.
Because delivery is at-least-once, handlers must be idempotent. See Handling events for handler patterns.
Both @DomainEventHandler (the framework-owned handler annotation) and @ApplicationModuleListener participate equally in the registry lifecycle.
The framework drives each @DomainEventHandler through ModulithDomainEventPublicationCompleter, which calls markProcessing, markCompleted, or markFailed on the EventPublicationRegistry using the listener’s registered identifier.
To resubmit publications that were incomplete when the process last stopped, enable Modulith’s restart-time resubmitter:
# Replay incomplete publications on application startup (Modulith property)
spring.modulith.events.republish-outstanding-events-on-restart=true
For high-throughput use cases, recent Spring Modulith versions also offer a transactional-outbox mode, which shifts the dispatch step out of the commit thread entirely:
spring.modulith.events.externalization.mode=outbox
See the Spring Modulith events reference for the full feature set.
Externalization
@Externalized — a Spring Modulith annotation — routes a domain event to a message broker (Kafka, RabbitMQ, or any supported transport) as an after-commit step driven by the same registry.
If the broker is unavailable the publication stays incomplete for later resubmission; the broker outage does not roll back the business transaction.
Spring DDD events are plain objects, so attaching @Externalized to a domain-event class is all that is needed:
@Externalized("banking.account.money-deposited") (1)
@DomainEvent(namespace = "banking", name = "MoneyDeposited")
data class MoneyDeposited(
@AggregateId val accountId: AccountId,
val amount: Money,
)
| 1 | @Externalized is a Spring Modulith annotation; the routing key is the topic or exchange name used by the configured transport. |
Externalization is a Modulith feature. Spring DDD’s events flow through it without any framework-specific wiring.
Native propagation of message metadata as broker message headers via @Externalized is a future enhancement.
A custom EventExternalizer can read CurrentMessageMetadata.get() today — see Durable domain-event metadata.
|
Event routes and @Externalized
Spring DDD’s event routes and Modulith’s own @Externalized are two independent ways to send a domain event to a broker, with no bridge between them.
Use one, or both for different events:
-
An event route is delivered by a route consumer through the
EventDispatcherthe application supplies for the destination’s transport. It writes no event publication, and it sends the metadata listed inspring.ddd.routing.headers. -
@Externalizedis delivered by Modulith from its event publication. Spring DDD stores the event’s metadata next to that publication and restores it when Modulith resubmits the publication (see Durable domain-event metadata), but Modulith sends none of it as broker headers unless a customEventExternalizerreadsCurrentMessageMetadata.get().
An event that is both routed and annotated @Externalized is sent twice.
Durable domain-event metadata
The gap
Message metadata (tenant, actor, correlation id, and other values declared with @MetadataValue) is bound to the event instance at publish time and is accessible from any handler on the same thread.
For live @DomainEventHandler and @ApplicationModuleListener calls this works automatically.
The gap appears on resubmission.
When Modulith restarts and replays an incomplete publication, or retries a failed one, the original event instance is freshly deserialized from the publication store.
The metadata that was attached at publish time is gone — the resubmitted handler sees MessageMetadata.EMPTY.
The bridge
Add spring-ddd-starter-domain-events-modulith-jdbc alongside a Modulith event-store starter:
implementation("de.dwittkoetter:spring-ddd-starter-domain-events-modulith-jdbc:0.0.1-SNAPSHOT")
// A Modulith event-store starter is also required — for example:
implementation("org.springframework.modulith:spring-modulith-starter-jpa")
// or: implementation("org.springframework.modulith:spring-modulith-starter-jdbc")
The starter auto-configures when Modulith’s EventPublicationRegistry is on the classpath.
It decorates Modulith’s EventPublicationRepository:
-
on publish — persists the event’s bound
MessageMetadatato a metadata table in the same transaction as the publication; nothing is written for events with no metadata. -
on resubmit — before Modulith re-multicasts an incomplete or failed publication, the decorator re-binds the stored metadata to the freshly deserialized event instance so that
@MetadataValue("tenant-id")andCurrentMessageMetadata.get()return the original values.
No spring.ddd.* properties are introduced; all configuration uses standard Modulith properties.
A custom event externalizer can also read CurrentMessageMetadata.get() and see the publish-time metadata — on first publish and on every post-restart replay, because Spring DDD restores the metadata before the event is externalized again.
Spring DDD restores the metadata scope around after-commit @EventListener-style listeners (including the @ApplicationModuleListener that drives Modulith externalization), so the value is reachable wherever externalization happens:
@Bean
fun metadataAwareExternalizer(
configuration: EventExternalizationConfiguration,
): DelegatingEventExternalizer =
DelegatingEventExternalizer(configuration) { target, payload -> (1)
val tenantId = CurrentMessageMetadata.get()["tenant-id"] (2)
// hand (target, payload, tenantId) to your transport — e.g. copy tenantId onto the outbound headers
CompletableFuture.completedFuture(null)
}
| 1 | A standard Spring Modulith externalizer bean; target is the routing target and payload is the event. |
| 2 | The publish-time metadata is in scope here, restored by Spring DDD around the after-commit listener — present on the first publish and on every post-restart resubmission. |
Retention
The metadata table follows Modulith’s own retention policy, controlled by spring.modulith.events.completion-mode:
| Mode | Effect on the metadata table |
|---|---|
|
The metadata is kept and stamped with its completion date when the publication completes. The metadata is removed by the same housekeeping that purges Modulith’s publications. |
|
The metadata is deleted as soon as the publication completes. |
|
The metadata is deleted when the publication completes. Archiving to a separate audit table is a future enhancement; a startup warning is emitted when this mode is active. |
Schema management
The metadata table, EVENT_PUBLICATION_METADATA, is created automatically when Modulith’s schema initialization is enabled:
# Opt-in (same switch that creates Modulith's own event publication table)
spring.modulith.events.jdbc.schema-initialization.enabled=true
The table is placed in the same schema prefix as Modulith’s own tables, controlled by spring.modulith.events.jdbc.schema.
@EnableDatabaseCleanup (see Database cleanup between tests) empties the metadata table between tests, in that schema.
Modulith’s own event publications are not emptied: a test that leaves incomplete publications behind cleans them as it would without Spring DDD.
Six dialects are supported: H2, HSQLDB, MariaDB, MySQL, PostgreSQL, and SQL Server.
For the exact CREATE TABLE statement for each dialect, see Database table schemas.
When auto-init is disabled (the default), the table must be created manually before the application starts. If the table is absent at startup a warning is logged — the application continues, but metadata will not be persisted and resubmitted handlers will see empty metadata.
Related pages
-
Handling events —
@DomainEventHandler, idempotency, and delivery semantics -
Message metadata — declaring and reading
@MetadataValueandCurrentMessageMetadata -
Sagas — at-least-once delivery for long-running process managers
-
Testing — testing event handlers and metadata in Spring DDD