§ concepts

The outbox: atomic state + events

The framework's central guarantee. Every action's commit is a single database transaction that touches as many domain tables as the action needs **plus** the `eventlog.events` outbox. They land together, or none of them do.

┌──────────────────  ONE DATABASE TRANSACTION  ──────────────────────┐
│                                                                    │
│  ┌────────────────┐  ┌────────────────┐  ┌────────────────────┐    │
│  │    wallets     │  │     orders     │  │  eventlog.events   │    │
│  │   (UPDATE)     │  │    (INSERT)    │  │     (INSERT)       │    │
│  ├────────────────┤  ├────────────────┤  ├────────────────────┤    │
│  │ id             │  │ id             │  │ id                 │    │
│  │ balance        │  │ wallet_id      │  │ action_id          │    │
│  │ version        │  │ amount         │  │ event_type         │    │
│  │ ...            │  │ status: placed │  │ payload (JSONB)    │    │
│  └────────────────┘  │ ...            │  │ ...                │    │
│                      └────────────────┘  └────────────────────┘    │
│         domain                domain               outbox          │
│                                                                    │
└────────────────────────────────────────────────────────────────────┘


       commit (all rows persist)  —or—  rollback (nothing persists)

This is the outbox pattern baked into the framework. There is no second write to a broker, no two-phase commit between database and Kafka, and no application-level retry loop trying to keep the two in sync. After commit, the rows in eventlog.events are the canonical record of what happened — downstream tooling (Debezium → Kafka) or in-process handlers consume them after the fact.

How rows get there

The outbox write is owned by EventPersister. The default implementation, SingleTableJsonEventPersister, runs inside ActionExecutor.persistChanges() after the domain rows have been written and before the transaction commits.

For each model in the action plan, ChangePersister extracts the events list, and SingleTableJsonEventPersister writes one row per event (or a single sentinel row if there are none — the action’s existence is always recorded). All rows for a given action share the same action_id. The namespace value comes from the ActionExecutor builder:

var executor = ActionExecutor.Builder.actionExecutor()
        .namespace("com.example.finance")    // → eventlog.events.namespace on every row
        .databaseRegistry(databaseRegistry)
        .objectMapper(objectMapper)
        .repositoryRegistry(repositoryRegistry)
        .actionRegistry(actionRegistry)
        .build();

event_type is the event class’s simple name, e.g. WalletMoneyDepositedEvent, not the fully-qualified package name. This keeps package moves from changing the wire/database contract. The default persister guards that contract at runtime: if one service emits two different event classes with the same simple name, it throws instead of writing ambiguous rows.

The on-disk shape of the outbox — the SQL DDL, the dialect-specific column types, the delivered column written on every insert, the event_notifications table the local-event-handler path adds for in-process dispatch, the indexes — lives in Outbox schema.

Two consumer paths

The outbox is just a table. Anything that can read it can consume it.

Both can run against the same outbox simultaneously. The in-process fan-out flips the delivered flag from false to true, generating UPDATE rows on eventlog.events; the Kafka SMTs drop non-INSERT operations so those flips are invisible to downstream topics.

See also