§ reference

Action

Action is the unit of business work. It stages new and updated domain objects onto its ActionPlan during Phase 1; the framework's ActionExecutor commits them in a single transaction during Phase 2.

Type signature

package io.ekbatan.core.action;

public abstract class Action<P, R> {

    protected Action(Clock clock);

    /** Phase 1 — read, build, stage. No DB writes. */
    protected abstract R perform(Principal principal, P params);

    /** Access the ActionPlan to stage adds/updates. */
    protected final ActionPlan plan();
}

P is the parameter type — usually a record you declare on the action itself. R is the return type — the value passed back to the caller of executor.execute(...).

Declaring 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());
        var updated = wallet.deposit(params.amount());
        return plan().update(updated);
    }
}

The @EkbatanAction annotation is a discovery marker for the DI integrations (Spring Boot, Quarkus, Micronaut). The integration registers the action instance in ActionRegistry, so ActionExecutor can route execute(WalletDepositAction.class, params) calls to it. Without DI, you register action instances manually with ActionRegistry.Builder.

@EkbatanAction does not configure execution behavior. Retries and cross-shard allowance are controlled by ExecutionConfiguration on the ActionExecutor default or on a single execute(...) call.

The two-phase lifecycle

        executor.execute(WalletDepositAction.class, params)


   ┌─── Phase 1 — perform() — no transaction ──────────────────┐
   │   read · build · attach events · plan.add / plan.update   │
   └───────────────────────────────────────────────────────────┘


   ┌─── Phase 2 — Executor.persistChanges() — one atomic TX ───┐
   │   1. group plan changes by ShardIdentifier                │
   │   2. inTransaction(shard, () -> {                         │
   │        Repository.addAll / updateAll  → domain rows       │
   │        EventPersister.persistActionEvents → outbox rows   │
   │        commit  ─or─  rollback                             │
   │      });                                                  │
   │   3. on configured retryable exception → retry whole call  │
   └───────────────────────────────────────────────────────────┘


                 result returned to the caller

Phase 1 is pure construction — reads are allowed, 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.

Methods

plan()

Returns the action’s ActionPlan. Call plan().add(newModel) to stage an insert; plan().update(updatedModel) to stage an update. Both return the staged model for fluent chaining (return plan().update(updated)).

perform(principal, params)

The method you implement. Receives the caller-supplied Principal (for auditing / authorization) and the action’s Params. Returns R — typically one of the models you staged, but free to be anything else.

Retry semantics

When a configured retryable exception is thrown, the framework discards the plan and re-runs Phase 1 from the start. The default ExecutionConfiguration retries StaleRecordException once after 100ms. You can change the retry policy for the whole executor via ActionExecutor.Builder.defaultExecutionConfiguration(...), or for one invocation by passing an explicit ExecutionConfiguration to execute(...).

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

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

The retry map can contain multiple exception classes. Matching is exact by exception class, and Ekbatan also checks the cause chain so wrapped retryable exceptions still retry. Superclass matching is not used. Exceptions not present in the effective retry config propagate to the caller.

Cross-shard execution

By default, an action’s staged changes must all route to a single shard. To allow staging changes across shards for one invocation, pass an ExecutionConfiguration with allowCrossShard(true):

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

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

The executor then opens one transaction per shard, in deterministic order, and rolls each back independently on failure. This is not a distributed transaction: one shard can commit while another fails. Treat allowCrossShard(true) as an escape hatch, prefer the per-call override, and use an executor default only for a dedicated executor whose actions are designed for per-shard commits and eventual consistency. There is no action-level @EkbatanAction switch. See Sharding for the consistency caveats and Sagas for the compensation pattern.

See also