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:
- which action committed,
- which model emitted the event,
- what event type was emitted,
- and the event payload as JSON.
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:
- Local handlers run inside the same application and react after the source action commits. Good for listen-to-yourself workflows, local projections, notification rows, and saga steps inside one service.
- Handler-published broker messages let application code read the event and manually publish to Kafka, Pulsar, RabbitMQ, SQS, or another broker when routing/enrichment is application-specific.
- CDC streaming lets Debezium or another CDC tool stream committed rows to a raw broker topic, where downstream processors can route, rekey, or fan out.
These paths can coexist because they all start from the same durable source: eventlog.events.
For the practical choices, read Consuming events.
See also
- Models and Entities — only Models emit events
- Actions, ActionPlan, ActionExecutor — what stages and commits the changes
- Framework tables — the SQL tables, columns, indexes, dialect specifics
- Consuming events — local handlers, broker publishing, CDC, and routing
- Listen-to-yourself — the in-process consumer path
- Streaming via Debezium → Kafka — the CDC consumer path