Getting started
Start here if you are new to Ekbatan. This page explains the problem Ekbatan solves and the basic flow it uses to save state and events together.
Ekbatan is useful when a service needs to save a business change and also make that change available as an event. The event might be handled inside the same service, or it might later be published to Kafka, Pulsar, RabbitMQ, or another broker.
The examples below use a wallet. A wallet is just an account with a balance. A deposit means adding money to that balance. The example is small on purpose, so the persistence and event flow are easy to see.
Read the page in order. It starts with the failure case, then introduces the outbox pattern, then shows how Ekbatan represents the same idea with models, actions, and an executor.
1. The Problem
Many event-driven services start with this shape:
request
-> write row to database
-> publish event to Kafka / Pulsar / RabbitMQ / ...
That looks normal, but it is two writes to two systems. If the database commit succeeds and the broker publish fails, the database says the business change happened but the event stream never hears about it. If the broker publish succeeds and the database commit fails, consumers react to a change that never committed.
That is the dual-write problem.
Two writes
✗ brokenCrash between writes ⇒ DB and Kafka disagree.
If an event must describe committed database state, the event needs to be saved with the database change. Publishing it as a separate operation can fail independently.
For the full motivation, read The dual-write trap.
2. The First Fix
The outbox pattern stores the event in the database first. An outbox is a table of committed events that can be read and delivered later.
The request flow becomes:
request
-> one database transaction
-> write state row
-> write event row
-> commit
-> publish later by reading the event row
The database is the transactional boundary. Either the state row and event row both commit, or neither does.
One write + outbox
✓ EkbatanCDC tails the outbox — events ship later, always in sync.
Publishing happens after the database commit by reading the committed event row.
For the conceptual version, read The outbox: atomic state + events.
3. Where The Event Comes From
In Ekbatan, a domain object that emits events is a Model. A domain object is an object from your business model, such as a wallet, order, or account.
When money is deposited into a wallet, the wallet does not just change its balance. It creates a new immutable wallet state and attaches a domain event that describes what happened.
public final class Wallet extends Model<Wallet, Id<Wallet>, WalletState> {
...
public Wallet deposit(BigDecimal amount) {
var newBalance = balance.add(amount);
return copy()
.withEvent(new WalletMoneyDepositedEvent(id, amount, newBalance))
.balance(newBalance)
.build();
}
...
}
The event is not a Kafka message. It is a domain fact: “money was deposited into this wallet.”
The event is created next to the state change, inside the domain model, before any code publishes anything to a broker.
For the deeper model rules, read Models and Entities.
4. What An Action Does
An Action is the unit of business work. A deposit action reads the current wallet, asks the model for the new wallet, and stages that new wallet on the action’s plan. The plan is a temporary list of objects the executor should persist after perform() finishes.
@EkbatanAction
public class WalletDepositMoneyAction extends Action<Params, Wallet> {
@Override
protected Wallet perform(Principal principal, Params params) {
var wallet = walletRepository.getById(params.walletId().getValue());
var updated = wallet.deposit(params.amount());
return plan().update(updated);
}
}
The important part is what does not happen here:
- no manual transaction
- no manual
INSERT INTO eventlog.events - no broker publish
- no second write to remember
perform() declares intent. It builds the next state and stages it.
perform() describes the state change. ActionExecutor persists the staged objects and events atomically.
For the full lifecycle, read Actions, ActionPlan, ActionExecutor.
5. What The Executor Commits
The caller invokes the action through ActionExecutor:
var wallet = executor.execute(
() -> "rest-user",
WalletDepositMoneyAction.class,
new WalletDepositMoneyAction.Params(walletId, amount));
The executor creates a fresh plan, calls perform(), and only then opens the transaction that writes everything:
ActionExecutor.execute(...)
|
v
Action.perform(...)
- read wallet
- build updated wallet
- attach WalletMoneyDepositedEvent
- plan().update(updated)
|
v
one DB transaction
- UPDATE wallets
- INSERT eventlog.events
- commit
If the wallet update fails because another request changed the same row first, the transaction rolls back. If any event row write fails, the wallet row rolls back too.
The outbox guarantee comes from the executor’s commit path: the event row is written in the same transaction as the state row.
For the API details, read ActionExecutor.
6. What Happens After Commit
After commit, eventlog.events contains a durable row. From there, you choose how to use it.
eventlog.events
|
+--> local event handler (database-backed, any service instance)
|
+--> handler that manually publishes to Kafka / Pulsar / RabbitMQ / ...
|
+--> Debezium CDC to a raw broker topic
|
+--> CDC raw topic -> router / stream processor -> derived topics
All of those paths start from the same committed row. They are downstream of the database transaction, so they cannot make the original state change partially committed.
They are also at-least-once delivery paths. A handler or broker consumer can run twice after a crash or retry. Use idempotency keys, unique constraints, or the outbox event id so duplicate delivery is harmless.
Ekbatan focuses on safely persisting events with state. Publishing and consumption are separate steps that read from the durable outbox row.
For the practical choices, read Consuming events.
7. Where The Rest Fits
The whole framework grows from the same small model:
- Repositories map immutable domain objects to jOOQ records.
- Models emit events; Entities do not.
- Actions stage new and updated objects.
- ActionExecutor commits staged state and events together.
- Local handlers, CDC, brokers, and routers consume committed outbox rows later.
- Sharding routes the same action model across multiple databases.
- Sagas coordinate multi-step workflows that should not be forced into one database transaction.
- Native image support packages the same model into a GraalVM binary.
You do not need all of those on day one. The important invariant stays the same:
business state + event row commit together
Suggested Next Steps
If you want to keep learning the model:
- The dual-write trap — understand the failure mode Ekbatan removes.
- The outbox: atomic state + events — see why state and event rows commit together.
- Models and Entities — learn which objects emit events and which do not.
- Actions, ActionPlan, ActionExecutor — understand the two-phase lifecycle before writing your first action.
- Your first Action — write the wallet deposit action and inspect the event row.
If you are ready to wire a real project:
- Complete project setup — dependencies, Flyway, jOOQ codegen, application config, and runnable examples for each supported stack and database.