§ concepts

Actions, ActionPlan, ActionExecutor

An **Action** is a unit of business work — `WalletDepositAction`, `OrderShipAction`, `SubscriptionRenewAction`. It does **not** write to the database directly. Instead, it stages new and updated domain objects onto its `ActionPlan`. Only after the action's `perform(...)` returns does the **`ActionExecutor`** open a single transaction, flush every staged change, write the outbox rows, and commit.

This split is what makes the outbox pattern implicit: domain rows and event rows are written together, atomically, by the framework — never by your code.

The two-phase lifecycle

        executor.execute(WalletDepositAction.class, params)


   ┌─── Phase 1 — Action.perform()  (no DB transaction yet) ────┐
   │                                                            │
   │   1. Read from repositories (primary or readonly DB)       │
   │   2. Build new immutable Models / Entities                 │
   │   3. Attach Events to the Models                           │
   │   4. Stage them on the ActionPlan:                         │
   │         plan.add(newOrder)                                 │
   │         plan.update(updatedWallet)                         │
   │   5. Return a result value                                 │
   │                                                            │
   │   No database writes in this phase.                        │
   └────────────────────────────────────────────────────────────┘


   ┌─── Phase 2 — Executor.persistChanges()  (one atomic TX) ───┐
   │                                                            │
   │   1. Group plan changes by ShardIdentifier                 │
   │   2. TransactionManager.inTransaction(shard, () -> {       │
   │        Repository.addAll / updateAll  →  domain rows       │
   │        EventPersister.persistActionEvents  →  outbox rows  │
   │        commit  ─or─  rollback                              │
   │      });                                                   │
   │   3. On StaleRecordException → retry whole action          │
   │                                                            │
   │   All writes land together, or none at all.                │
   └────────────────────────────────────────────────────────────┘


                  result returned to the caller

Phase 1 is pure construction — reads are allowed, but no writes happen. Phase 2 is the only place the framework opens a transaction, and it always wraps every staged change plus the matching event rows together. Anything that throws inside Phase 2 rolls the whole transaction back.

Writing an action

@EkbatanAction
public class WalletDepositAction extends Action<WalletDepositAction.Params, Wallet> {

    public record Params(Id<Wallet> walletId, BigDecimal amount) {}

    private final WalletRepository walletRepository;

    public WalletDepositAction(Clock clock, WalletRepository walletRepository) {
        super(clock);
        this.walletRepository = walletRepository;
    }

    @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 action receives the principal, takes a typed Params record, reads from a repository, builds a new immutable Wallet instance with an event attached (via wallet.deposit(...)), and stages the result with plan().update(...). No database write happens in perform().

Calling it:

Wallet result = executor.execute(
        () -> "alice",
        WalletDepositAction.class,
        new WalletDepositAction.Params(walletId, new BigDecimal("25.50")));

The executor opens one transaction, writes the new wallet row with its incremented version, writes the WalletMoneyDepositedEvent row into eventlog.events, and commits. If anything throws, both rows are rolled back together.

ActionPlan

ActionPlan is a single-writer, in-memory accumulator. Inside perform(), code reaches it via plan() (a protected helper on Action):

plan().add(entity);          // staged for INSERT
plan().update(entity);       // staged for UPDATE; returns entity.nextVersion()
plan().addAll(collection);
plan().updateAll(collection);

A few rules:

Singletons + per-call state via ScopedValue

Every Action subclass is a singleton. Exactly one instance per class lives for the lifetime of the application; that instance is shared across every concurrent invocation. The instance’s only state is the Clock and constructor-injected dependencies.

Per-execution mutable state — specifically the ActionPlan — is bound by the framework into a java.lang.ScopedValue for the duration of perform(), and accessed via the protected plan() method. Because of this:

Plan is single-writer; spawning threads is fine

Spawning parallel threads inside Action.perform() is allowed — the action’s plan is single-writer, not the action itself. The two hard rules:

  1. Only the main thread (the one that invoked perform()) may call plan().add(...) or plan().update(...). ActionPlan is a plain LinkedHashMap internally — concurrent mutations from spawned threads are a data race. Even a single read from a spawned thread is unsupported because the ScopedValue binding doesn’t propagate, so plan() will throw IllegalStateException.
  2. Don’t share the action’s transactional Connection across threads. The TransactionManager binds it via ScopedValue to the main thread; spawned children don’t see it (and a JDBC Connection isn’t thread-safe anyway).

The supported pattern: fan out the work on virtual threads, join the results, and only then mutate the plan from the main thread:

@Override
protected Order perform(Principal principal, Params params) throws Exception {
    // Spawn parallel reads — none of these touch plan()
    var customerThread = Thread.startVirtualThread(() -> customerService.fetch(params.customerId()));
    var pricingThread  = Thread.startVirtualThread(() -> pricingService.quote(params.lineItems()));
    customerThread.join();
    pricingThread.join();

    // Back on the main thread — now it's safe to mutate the plan
    var order = createOrder(/* aggregated results from the joined threads */).build();
    return plan().add(order);
}

For reads, prefer doing the parallel fan-out before calling executor.execute(...) — i.e. at the caller layer — so the parallel work uses connections from the regular pool rather than the action’s transactional connection. Inside perform(), keep parallelism limited to work that doesn’t need the action’s transaction (external API calls, replica-only reads via fresh DSLContexts, computation).

No nested actions, no sagas

Actions must not invoke other actions inside perform(). The framework intentionally does not support nesting or composition — an action is a self-contained unit of business work that produces a single atomic transaction. Nesting blurs transaction boundaries, creates hidden coupling, and makes the execution flow hard to reason about.

If two operations must happen together, they belong in a single action. If they are independent, execute them separately from the caller. If one must follow the other, orchestrate the sequence at the service / application layer above the framework.

The exception is listen-to-yourself: an EventHandler running in the local-event-handler dispatch path can invoke an action via the injected ActionExecutor. That’s a valid pattern because the handler runs after the source action has committed, in its own transaction context.

Retries on optimistic-lock conflicts

Every persistable carries a version. Updates always include WHERE version = ?. If another transaction modified the same row in the meantime, the update affects zero rows and the framework throws StaleRecordException, unwinding the entire transaction.

The default ExecutionConfiguration retries StaleRecordException once with a 100ms delay. You can tune the policy — or remove it — per executor or per call:

ExecutionConfiguration.Builder.executionConfiguration()
        .withRetry(StaleRecordException.class, new RetryConfig(3, Duration.ofMillis(50)))
        .build();

The retry replays the entire action from Phase 1 with a fresh plan. Side effects in perform() outside the plan (logging, counters, external API calls) will replay too — keep perform() pure.

Cross-shard actions

Actions that touch multiple shards are rejected by default: the executor throws CrossShardException if changes span shards. Opt in per call:

var config = ExecutionConfiguration.Builder.executionConfiguration()
        .allowCrossShard(true)
        .build();

executor.execute(principal, MyAction.class, params, config);

When enabled, each involved shard gets its own transaction (commits independently — there is no 2PC), and the action’s row in eventlog.events is duplicated to every shard with the same UUID so each shard contains the full action context.

See Sharding for the full picture.

See also