Skip to content
All posts

Transactional Outbox with Spring Modulith Events

By 9 min read

When an order is placed, other parts of the system need to react: reserve stock, send a confirmation, publish a message to Kafka. Doing that reliably is harder than it looks. React inside the order’s transaction, and a failure in the stock code cancels the order. React after the commit, and a crash at the wrong moment loses the reaction for good.

The transactional outbox pattern solves this. The event is stored in the same database transaction as the order, then delivered afterwards and retried until it succeeds. Spring Modulith’s event publication registry implements that pattern for ordinary Spring application events. This post continues the shop example from Spring Modulith: Build a Modular Monolith in Spring Boot, on Spring Boot 3.4 and Spring Modulith 1.3.

Why a plain event listener is not enough

The orders module publishes an OrderCreatedEvent after saving, and inventory listens for it:

inventory/InventoryService.java
@EventListener
void on(OrderCreatedEvent event) {
    inventory.reserve(event.productCode(), event.quantity());
}

@EventListener runs synchronously on the publishing thread, inside the order’s transaction. If reserve throws, the order rolls back. If it is slow, placing an order is slow. The modules are separate in the code but still tied together at runtime.

@TransactionalEventListener moves the listener to after the commit. That fixes the coupling, but the event now exists only in memory. If the listener fails, or the application stops between the commit and the listener running, the stock is never reserved and nothing records that it should have been. This is the classic dual write problem: two things that must both happen, and no transaction covering both.

How the event publication registry works

With the registry on the classpath, publishing an event inside a transaction also writes one row per transactional listener into an event_publication table. Because that write happens in the business transaction, the order and its pending events commit together, or not at all.

Spring Modulith event publication registry: the order and an incomplete event publication are inserted in one transaction; after commit the listener runs in its own transaction and marks the publication complete, or leaves it incomplete to be resubmitted
The publication is stored with the order and only marked complete once the listener has succeeded.

After the commit, each listener runs. When it returns normally, its publication is marked complete. When it throws, the publication stays incomplete, and the event is still in the database, serialized as JSON, ready to be delivered again.

Setting it up

Pick one registry starter. The JDBC variant works in JPA applications too and can create its own table:

build.gradle
implementation 'org.springframework.modulith:spring-modulith-starter-jdbc'
application.properties
# create the event_publication table on startup (off by default before Modulith 2.1)
spring.modulith.events.jdbc.schema-initialization.enabled=true
# optional: keep it in its own schema
spring.modulith.events.jdbc.schema=events

spring-modulith-starter-jpa is the alternative. It stores publications through a JPA entity, so create the table with your migrations (Flyway or Liquibase) like any other.

Listening with @ApplicationModuleListener

inventory/InventoryService.java
@Service
class InventoryService {

    @ApplicationModuleListener
    void on(OrderCreatedEvent event) {
        inventory.reserve(event.productCode(), event.quantity());
    }
}

@ApplicationModuleListener is shorthand for three annotations:

  • @TransactionalEventListener: runs after the order’s transaction commits, never for a rolled-back order.
  • @Async: runs on another thread, so placing the order does not wait for inventory.
  • @Transactional(propagation = REQUIRES_NEW): the listener gets a transaction of its own.

It relies on Spring’s @Async support, so add @EnableAsync to a configuration class if nothing in your application enables it yet. The registry covers any @TransactionalEventListener, but this one annotation captures the combination you almost always want between modules.

Retrying incomplete publications

Incomplete publications stay in the table until something resubmits them. There are two ways to do that.

On restart

application.properties
spring.modulith.events.republish-outstanding-events-on-restart=true

This is simple and fits a single instance. With several instances, each one republishes everything on startup, which the Spring Modulith documentation advises against.

On a schedule

IncompleteEventPublications resubmits on demand. Only retry publications older than a few minutes, so you don’t resubmit events whose listeners are still running:

RetryIncompletePublications.java
@Component
class RetryIncompletePublications {

    private final IncompleteEventPublications incomplete;

    RetryIncompletePublications(IncompleteEventPublications incomplete) {
        this.incomplete = incomplete;
    }

    @Scheduled(fixedDelay = 60_000)   // needs @EnableScheduling
    void retry() {
        incomplete.resubmitIncompletePublicationsOlderThan(Duration.ofMinutes(5));
    }
}

With several instances, make sure only one runs the job at a time, for example with ShedLock.

Cleaning up completed publications

By default a completed publication stays in the table with its completion date set, which is handy for auditing and grows forever. Since Spring Modulith 1.3 you can choose what happens on completion:

application.properties
# update (default): keep the row, set the completion date
# delete: remove the row as soon as the listener succeeds
# archive: move the row to event_publication_archive
spring.modulith.events.completion-mode=delete

If you keep the default, purge old rows on a schedule with CompletedEventPublications.deletePublicationsOlderThan(…).

Testing a listener

Spring Modulith’s Scenario can publish an event and wait for the asynchronous listener to finish its work:

inventory/InventoryModuleTests.java
@ApplicationModuleTest
class InventoryModuleTests {

    @Autowired
    InventoryService inventory;

    @Test
    void reservesStockWhenAnOrderIsCreated(Scenario scenario) {
        scenario.publish(new OrderCreatedEvent("ORD-1", "P100", 2))
                .andWaitForStateChange(() -> inventory.reservedQuantity("P100"), qty -> qty == 2)
                .andVerify(qty -> assertThat(qty).isEqualTo(2));
    }
}

Sending events to Kafka through the same outbox

When other services need the event too, mark it for externalization and add the Kafka module (spring-modulith-events-kafka; there are AMQP, JMS and Spring Messaging variants as well):

orders/domain/events/OrderCreatedEvent.java
@Externalized("orders.created::#{#this.orderNumber()}")
public record OrderCreatedEvent(String orderNumber, String productCode, int quantity) {}

The part before :: is the Kafka topic. The expression after it becomes the message key, so all events for one order land in the same partition, in order. Externalization is itself a transactional event listener, so it goes through the registry like any other: if Kafka is down, the publication stays incomplete and is retried, which is exactly what a transactional outbox to a broker should do.

On Spring Modulith 2

Spring Modulith 2.x, for Spring Boot 4, extends the registry. Each publication now has an explicit status (published, processing, completed, failed, resubmitted). A staleness monitor flags publications stuck in processing, and FailedEventPublications resubmits failures with options. From 2.1, JDBC schema initialization is on by default. The concepts and annotations in this post stay the same.

Checklist

  • One registry starter (-jdbc or -jpa), and the table created by Modulith or by your migrations.
  • @ApplicationModuleListener between modules, with async execution enabled.
  • Idempotent listeners: delivery is at least once.
  • A retry strategy: republish on restart for one instance, or a scheduled IncompleteEventPublications job.
  • A completion mode or a purge job, so the table doesn’t grow forever.
  • @Externalized for events other services need, so the broker gets the same guarantees.