§ learn

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 inside the request, 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 durable record of what happened. Anything that publishes, handles, routes, or transforms events runs later by reading those committed rows.

How rows get there

Ekbatan gets an event row into the database through the same flow every time:

Model method
  -> returns a new immutable Model
  -> attaches one or more ModelEvents

Action.perform()
  -> reads current state
  -> calls the Model method
  -> stages the new Model on plan()

ActionExecutor
  -> opens one database transaction
  -> writes the domain row
  -> writes the attached event rows to eventlog.events
  -> commits or rolls back everything together

For a wallet deposit, that looks like this:

public Wallet deposit(BigDecimal amount) {
    var newBalance = balance.add(amount);

    return copy()
            .withEvent(new WalletMoneyDepositedEvent(id, amount, newBalance))
            .balance(newBalance)
            .build();
}

The event is attached before persistence, but it is not written immediately. It waits in the action’s staged changes. Only the executor writes it, in the same transaction as the wallet row.

That is the important part: application code creates domain facts; the framework persists those facts atomically with the state they describe.

What the outbox row means

An outbox row is not “a Kafka message stored in SQL.” It is a committed domain event with enough metadata for consumers to understand where it came from:

The exact SQL columns, indexes, dialect differences, sentinel rows, and local-handler delivery tables live in Framework tables. You do not need those details to understand the guarantee.

What happens after commit

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

Common paths:

These paths can coexist because they all start from the same durable source: eventlog.events.

For the practical choices, read Consuming events.

See also