§ learn

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

✗ broken

Crash 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

✓ Ekbatan
app
database (one tx)
state
events
Kafka
consumer

CDC 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:

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:

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:

  1. The dual-write trap — understand the failure mode Ekbatan removes.
  2. The outbox: atomic state + events — see why state and event rows commit together.
  3. Models and Entities — learn which objects emit events and which do not.
  4. Actions, ActionPlan, ActionExecutor — understand the two-phase lifecycle before writing your first action.
  5. Your first Action — write the wallet deposit action and inspect the event row.

If you are ready to wire a real project: