Skip to content

Event Delivery Inside One Application: Choosing Between Spring Modulith Publications and an Outbox

3D miniature of robotic engineers arranging an approved order, event publication ledger, fulfillment reservation, and external delivery boundary
Leaving a row behind is not the key distinction. What matters is who reads it again, and across which execution boundary.

Consider approving an order and then reserving stock. If the order row becomes APPROVED and the stock reservation fails afterward, approving the order again is the wrong recovery step. A log line is not enough either: an operator still needs to know which order stopped and which handler should run again.

It is tempting to conclude, “this is an event, so use an outbox.” That is a sound conclusion when the consumer lives behind a broker or in another service. When the orders and fulfillment modules are inside the same Spring Boot application, the first question is different: did the follow-up work between modules finish?

This post uses the Spring Modulith publication example in exposed-workshop to draw that boundary. A Spring Modulith publication is not a lighter transactional outbox. It is a local durability layer that records whether a listener in the same application finished after commit and what should be run again after failure.

The Order Was Approved, but Stock Reservation Failed

Section titled “The Order Was Approved, but Stock Reservation Failed”

The business flow has two steps:

  1. The orders module approves an order.
  2. The fulfillment module receives the approval event and reserves stock.

Putting both steps near the same database transaction does not mean stock reservation can never fail. The listener can run after the order commits, and an exception can happen in between. The order is already approved at that point, so the system needs to recover the failed follow-up work instead of replaying the original command as a whole.

Spring Modulith publications persist the questions that matter at this point:

  • Which event was published?
  • Which listener has not finished?
  • Has the failed work been sent again?

That record does not mean an external message arrived. It records whether the stock-reservation handler finished inside the same application.

Start the Order and Event in One Transaction

Section titled “Start the Order and Event in One Transaction”

The example’s OrderApplicationService.approve stores an APPROVED order and publishes OrderApprovedEvent inside a TransactionTemplate.

fun approve(command: ApproveOrderCommand): OrderSummary =
transactionTemplate.execute {
val approvedAt = Instant.now()
val id = WorkshopOrders.insertAndGetId { row ->
row[orderKey] = command.orderKey
row[customerId] = command.customerId
row[status] = "APPROVED"
row[WorkshopOrders.approvedAt] = approvedAt
}
val summary = WorkshopOrders.selectAll()
.where { WorkshopOrders.id eq id }
.single()
.toOrderSummary()
events.publishEvent(
OrderApprovedEvent(summary.orderKey, summary.customerId, summary.approvedAt ?: approvedAt)
)
summary
}

This code establishes the starting point for the order and its event. A rolled-back transaction leaves neither the approval nor the event. Once it commits, a later listener failure does not erase the fact that an approval event exists and still has work to finish.

The example replaces Spring Modulith’s EventPublicationRepository with an Exposed implementation. It participates in springTransactionManager and writes a listener id, event type, serialized event, status, completion attempts, and publication/completion timestamps to EVENT_PUBLICATION. The table is not a duplicate copy of the order. It records how far one listener has processed one event.

A Publication Completes Only When Its Handler Completes

Section titled “A Publication Completes Only When Its Handler Completes”

The fulfillment module is a local listener for OrderApprovedEvent.

@ApplicationModuleListener(id = "fulfillment.reserve-stock")
fun reserveStock(event: OrderApprovedEvent) {
transactionTemplate.executeWithoutResult {
FulfillmentReservations.insert { row ->
row[orderKey] = event.orderKey
row[customerId] = event.customerId
row[reservedAt] = Instant.now()
}
}
}

An @ApplicationModuleListener is a transactional, asynchronous listener. Spring Modulith captures the publication before running the listener and marks the row complete only after the listener finishes successfully. A failure between order commit and stock reservation therefore leaves an actionable publication rather than a question about whether the order should be rolled back.

Diagram comparing Orders, Transaction, OrderApprovedEvent, EVENT_PUBLICATION, fulfillment reserve-stock completion and resubmission inside one Spring Boot application with a separate Outbox, broker, and external consumer delivery boundary
The top path tracks listener completion inside one application. The lower path starts the work of delivering to a broker and an external consumer.

The two lines in the diagram are not competing implementations. The upper path observes completion of the local fulfillment.reserve-stock handler. The lower path starts delivery to a broker and external consumer. They may use an event with the same name, but the recovery owner and execution environment differ.

A Failed Publication Is an Operational Item

Section titled “A Failed Publication Is an Operational Item”

The example test makes stock reservation fail once and verifies that a FAILED publication remains. Calling IncompleteEventPublications.resubmitIncompletePublications(...) sends that publication again. After reservation succeeds, its completion attempts become 2.

Resubmission is not another call to the order-approval API. It finds failed listener work by its publication row and runs it again. Operational tools therefore need to show the following together.

What to inspectWhy it matters
Listener idIt identifies the module’s follow-up work that stopped.
Status and attempt countIt distinguishes the first failure from work that was already resubmitted.
Event type and serialized eventIt shows which business input will run the handler again.
Publication and completion timeIt helps judge processing delay and recovery time.

There is a more awkward case too. An old publication row can remain after a deployment removes an event class. The example does not hide that row from inspection. Accessing publication.event, however, raises UnloadableEventPublicationException. That is an operational signal to restore the event class or migrate/delete the row, not a reason to press retry blindly.

Inside One Application and Outside It Are Different Delivery Boundaries

Section titled “Inside One Application and Outside It Are Different Delivery Boundaries”

Both stores leave rows behind, so they can look similar at first. Their questions make the distinction clear.

Decision pointSpring Modulith publicationTransactional Outbox
Consumer locationAnother module in the same Spring Boot applicationAnother process, service, broker, or runtime
Stored recordListener completion, failure, and resubmission stateIntent to publish an event externally
Primary failureLocal listener work after commitNetwork delivery, broker, or external-consumer failure
Operational questionWhich listener has not finished?Which event has not reached the outside yet?
Recovery scopeRe-run a listener inside the same deployment unitThe integration path through relay, broker, and consumer

When the consumer remains in the same application and an @ApplicationModuleListener resubmission is enough, a publication store keeps the problem small. When an event must reach Kafka, another service, or a different runtime, an outbox and relay are needed. The publication store does not replace broker delivery, and an outbox does not by itself describe whether a local listener completed.

Use this sequence to decide from the boundary instead of from a pattern name.

  1. Is the consumer inside the same application? Start with module events and listener-failure recovery.
  2. Is recovery inside the same deployment unit sufficient? Design the operational flow for listing and resubmitting incomplete publications.
  3. Must the event reach an external consumer, broker, or separate runtime? Design an outbox boundary between the database change and external publication.

One business flow can need both boundaries. Orders can notify fulfillment through a local publication while sending an event to a delivery-partner service through an outbox. Even then, the two rows should not be treated as interchangeable copies. Define the state and operations for local completion and external delivery separately.

Leaving a row in a store does not make two designs the same. Spring Modulith publications track and rerun listeners inside one application. Transactional outboxes carry events across an external execution boundary and tolerate delivery failures there. Start by locating the consumer, and the state model and operating procedure follow from that boundary.

Comments

Leave a note or reaction with your GitHub account.