Damian Garstecki
4 min read

Getting Free Outbox with Spring Modulith

Why even bother with an outbox for internal events?

Early in the app lifecycle you can get away with one big transaction: change domain state, flush, all or nothing is committed. Simple. Then growth hits. New features keep adding more into the persistence context. Optimistic locks start colliding. Commits take longer. The natural reaction is to peel side effects away into domain events handled after commit.

That helps until an event disappears between commit and handling. Classic case: user pays, subscription switches to active, accounting never updates because the process died at the wrong moment. State changed. Side effect lost.

The outbox pattern closes that gap. It keeps “state changed” and “emit event” in the same durable unit.

Quick refresher: what the Outbox pattern looks like

In a transaction that changes domain state, you also persist an event record. It’s not sent anywhere yet, just stored in the database. A background publisher/dispatcher reads pending rows and delivers them. The transaction guarantees durability of both the state and the event — the event is only saved with the rest of the business state. Delivery can be retried separately.

This is a very short refresher. For larger context read Chris Richardson’s blog post.

Common pain points when deciding to roll your own Outbox implementation:

  • Concurrency (multiple app instances racing the same rows)
  • Ordering vs parallelism trade‑off
  • Backoff / retry semantics
  • Cleaning up processed rows
  • Idempotency hints (dedupe keys)

We could try to solve all those problems on our own, but Spring Modulith integrates nicely into the Spring ecosystem. Let’s reuse it instead of re‑inventing the wheel.

Spring Modulith as an Outbox

Main selling point of Spring Modulith is helping modularize an application; the outbox is a side effect. This article focuses only on that part.

Spring Modulith’s Event Publication Registry stores published application events until listeners complete. That is effectively an outbox table coupled with the default Spring event platform — ApplicationEventPublisher. By enabling the Spring Modulith JPA starter we automatically upgrade the well‑known publisher into one that comes with an outbox. All existing events in the app start using it as well.

We start by including the JPA starter:

implementation("org.springframework.modulith:spring-modulith-starter-jpa")

We also need a table in our database to store the events. In my case (PostgreSQL) I add this to my migration manager:

CREATE TABLE IF NOT EXISTS event_publication
(
    id               UUID NOT NULL,
    listener_id      TEXT NOT NULL,
    event_type       TEXT NOT NULL,
    serialized_event TEXT NOT NULL,
    publication_date TIMESTAMP WITH TIME ZONE NOT NULL,
    completion_date  TIMESTAMP WITH TIME ZONE,
    PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS event_publication_serialized_event_hash_idx ON event_publication USING hash(serialized_event);
CREATE INDEX IF NOT EXISTS event_publication_by_completion_date_idx ON event_publication (completion_date);

Up‑to‑date variants for all supported databases are in the official docs

We are almost done. As an extra you might want to enable these properties:

spring.modulith.events.republish-outstanding-events-on-restart=true
spring.modulith.events.completion-mode=delete

What they do:

  • republish-outstanding-events-on-restart=true: resend unfinished events after app restart (soft self‑recovery)
  • completion-mode=delete: delete processed rows so the table stays small and the outbox won’t slow down over time

And that’s it. We don’t need anything else to start using it.

Publishing events

You publish events just like before using ApplicationEventPublisher. Modulith now also persists them.

Simple example:

data class Event(val name: String)

@Service
class Service(
    private val publisher: ApplicationEventPublisher
) {
    @Transactional
    fun businessAction(name: String) {
        // domain logic
        publisher.publishEvent(Event(name))
    }
}

Looks simple, but there are two potential pitfalls:

  1. The method must be @Transactional — even if business logic doesn’t store anything at all — because publishEvent now writes to the database. If we omit @Transactional the event is still sent but without the outbox.
  2. Use small immutable payloads. The whole payload is serialized and persisted on a database. If you publish an Entity the serializer will dump the entire entity graph.

Consuming events

Consumption is unchanged. We consume events as before. Spring Modulith also provides a helpful annotation @ApplicationModuleListener, which wraps async + new transaction + event listener in one go. In most cases you want those three annotations on a listener anyway.

Basic handler:

@Service
class Handler {
    @ApplicationModuleListener
    fun onEvent(event: Event) {
        // handle side effects
    }
}

You can add @Retryable for more resilience, if it is configured and enabled in your app.

Conclusion

Modulith gives you an internal outbox “for free”: transactional write + event record, async handling, replay on restart and cleanup. It is not a message bus and that is fine. Keep handlers idempotent in case of multiple deliveries. Keep events small; don’t bloat them by attaching full entities. If later you decide those events must be published into Kafka, Modulith provides an extension (not covered here).

To view a full project example: https://gitlab.com/garstecki/spring-modulith-outbox

spring bootarchitecturedddeventsoutbox