Understanding Steps

In the previous sections you saw awaitExecute and awaitEvent in action. This section covers the full configuration available across all step types—timeouts, payload mapping, event naming, error handling, and execution semantics.

Everything in this section applies to all step primitives—unless noted otherwise.

Primitives cannot be nested. Do not call execute, awaitExecute, awaitEvent, sleep, fail, or cancel from inside an action passed to another primitive. Each primitive must be called directly from the workflow method—the engine tracks execution through the top-level call sequence, and nesting would break event ordering and replay.

// WRONG — do not nest primitives
ctx.awaitExecute("outer", Map.of(), (pc, payload) -> {
    ctx.awaitExecute("inner", ...);  // this will not work correctly
    return Map.of();
});

// CORRECT — call primitives sequentially from the workflow method
ctx.awaitExecute("stepA", Map.of(), ServiceA::doWork);
ctx.awaitExecute("stepB", Map.of(), ServiceB::doWork);
In this section

Step primitives at a glance

Primitive Blocking Returns Covered in

awaitExecute

Yes

Typed result

Execute Steps

execute

No

WorkflowStepResult

Execute Steps

awaitEvent

Yes

Typed event

Waiting for Events

waitForEvent

No

WorkflowStepResult

Waiting for Events

sleep

Yes

void

Waiting for Events

sleepAsync

No

WorkflowStepResult

Waiting for Events

For awaitExecute and execute overloads, usage examples, and Kotlin equivalents, see Execute Steps.

WorkflowStepResult

The WorkflowStepResult returned by execute and waitForEvent lets you inspect, wait for, or cancel a running step.

Table 1. Blocking methods—wait until the step reaches a terminal state
Method Description

await()

Blocks until the step finishes. Use when you don’t need the outcome, just the guarantee it’s done.

success()

Blocks, then returns true if the step completed successfully.

failure()

Blocks, then returns true if the step threw an exception.

timeout()

Blocks, then returns true if the step exceeded its timeout.

canceled()

Blocks, then returns true if the step was cancelled.

Table 2. Non-blocking methods—return immediately
Method Description

getStepName()

Returns the step name.

isCompleted()

Returns true if the step has already reached a terminal state.

result()

Returns Optional<T> with the result payload. Empty if the step hasn’t finished yet.

error()

Returns Optional<StepFailedException>. Empty if the step hasn’t finished or didn’t fail.

cancel() / cancel(reason)

Cancels the step if still running. No-op if already in a terminal state. See Cancelling a step.

Both execute and waitForEvent return a WorkflowStepResult:

// non-blocking execute
var shipping = ctx.execute("shipOrder", ctx.workflowPayload(),
        ShippingService::shipOrder,
        Duration.ofMinutes(5), defaults());

// non-blocking wait for event
var approval = ctx.waitForEvent("awaitApproval",
        ManagerApproved.class, Duration.ofMinutes(30));

// check and wait on either
if (shipping.isCompleted()) {
    logger.info("Shipping already done!");
}

approval.await(); // block until the event arrives or times out

if (approval.timeout()) {
    logger.warn("Approval timed out!");
}

Timeouts

Every step has a timeout. If the action doesn’t complete in time, the engine records a TIMED_OUT event and the step is considered failed. This applies to awaitExecute, execute, and awaitEvent alike.

Default

5 seconds—used when you don’t pass an explicit Duration.

Override per step

Pass a Duration parameter to awaitExecute, execute, or awaitEvent.

// Uses the default 5-second timeout
ctx.awaitExecute("quickCheck", Boolean.class, QuickService::check);

// Explicit 30-second timeout for an action
ctx.awaitExecute("slowOperation", ctx.workflowPayload(),
                 SlowService::process,
                 Duration.ofSeconds(30), defaults());

// Explicit 15-minute timeout for waiting on an external event
ctx.awaitEvent("awaitPayment", PaymentConfirmed.class,
               Duration.ofMinutes(15));

The timeout is measured from the timestamp of the STARTED event—that is the step’s start time. On replay after a crash, the remaining timeout is recalculated: startTime + timeout - now. If the remaining time is already negative, the step times out immediately.

Payload reducers

Every workflow has a global payload—the shared state that lives for the entire workflow. When you call a step, you can also pass a local payload—data specific to that step.

The question is: what does the step’s action actually receive as input, and what happens to the step’s result? That’s what payload reducers control.

The two reducers

Each step has two reducers:

parameterMapping

Controls the input—what payload the step’s action receives.

resultMapping

Controls the output—what happens to the step’s result after it completes.

Available options

Reducer As input (parameterMapping) As output (resultMapping)

LOCAL_ONLY (default for input)

The action receives only the local payload you passed in. It doesn’t see the global workflow state.

Only the local result is kept. The global payload is untouched.

GLOBAL_ONLY (default for output)

The action receives only the global workflow payload. The local payload is ignored.

The step’s result is discarded. The global payload stays as-is.

COMBINE_GLOBAL_AND_LOCAL

The action receives both merged—local values overwrite global values with the same key.

The step’s result is merged back into the global payload.

Why does this matter?

With the defaults (LOCAL_ONLY input, GLOBAL_ONLY output), steps are isolated—they only see what you explicitly pass them, and their results don’t leak into the global state. This keeps things predictable.

Sometimes you want a step to see the full workflow context, or you want the result to update the global payload for subsequent steps. That’s when you change the reducer.

Example

Imagine a workflow with a global payload {orderId=123, customerId=456, email=joe@example.com}.

// Default behavior: step only sees the local payload
ctx.awaitExecute("reserveStock",
        payload("customerId", customerId).getValues(), (1)
        InventoryService::reserveStock);
1 The action receives {customerId=456} only—it can’t see orderId or email.
// Step sees everything: global + local merged
awaitExecute(
    "processOrder",
    { payload ->
        // payload = {orderId=123, customerId=456, email=joe@example.com, priority=high}
        OrderService.process(payload)
    },
    local = mapOf("priority" to "high"),
    parameterMapping = PayloadReducer.COMBINE_GLOBAL_AND_LOCAL, (1)
    resultMapping = PayloadReducer.COMBINE_GLOBAL_AND_LOCAL     (2)
)
1 The action receives the global payload merged with the local priority field.
2 The step’s result is merged back into the global payload, so subsequent steps can see it.

In Java, the full-form awaitExecute with PayloadProcessor uses LOCAL_ONLY for input and GLOBAL_ONLY for output by default.

Error handling

When a step fails, you need to decide: recover with a compensating action, or terminate the workflow? The approach depends on whether you’re using blocking (awaitExecute) or non-blocking (execute) steps.

Blocking steps—try/catch

awaitExecute throws a StepFailedException when a step fails. Catch it to run a compensating action, or let it propagate to fail the workflow:

try {
    ctx.awaitExecute("chargePayment",
            payload("customerId", customerId, "amount", amount).getValues(),
            PaymentService::charge);
} catch (StepFailedException e) {
    // Compensating action: refund the reserved stock
    ctx.awaitExecute("releaseStock",
            payload("customerId", customerId).getValues(),
            InventoryService::releaseStock);

    ctx.fail(new RuntimeException("Payment failed: " + e.getMessage())); (1)
}
1 After the compensating action, explicitly fail the workflow. Without this, the workflow would continue to the next step.

Non-blocking steps—check the result

With execute and waitForEvent, steps run asynchronously. Check the result to handle errors:

var paymentConfirmation = ctx.waitForEvent("awaitPayment",
        PaymentConfirmed.class, Duration.ofMinutes(15));

var shipping = ctx.execute("shipOrder", ctx.workflowPayload(),
        ShippingService::shipOrder,
        Duration.ofMinutes(5), defaults());

if (paymentConfirmation.timeout()) { // blocks until terminal state
    ctx.fail(new RuntimeException("Payment confirmation timed out"));
}

if (shipping.failure()) { // blocks until shipping reaches a terminal state
    // Compensating action for failed shipping
    ctx.awaitExecute("refundPayment",
            payload("customerId", customerId).getValues(),
            PaymentService::refund);

    ctx.fail(new RuntimeException("Shipping failed: " + shipping.error().get().getMessage()));
}

What gets thrown?

Scenario Exception

Step action throws an exception

StepFailedException wrapping the original cause

Step times out

StepFailedException (for awaitExecute), or check result.timeout() (for execute)

Step is cancelled

StepCancellationException

Workflow is failed via ctx.fail()

WorkflowFailedException

Workflow is cancelled via ctx.cancel()

WorkflowCancelledException

Any exception thrown during step execution is wrapped in StepFailedException. Use getCause() to access the original exception.

Cancelling a step

You can cancel a running step in two ways:

Via the result handle:

var approval = ctx.waitForEvent("awaitApproval",
        ManagerApproved.class, Duration.ofMinutes(30));

// later — manager rejected, no longer need approval
approval.cancel("Manager rejected the request"); (1)
1 Cancels the wait. The workflow continues executing—only this step is affected.

Via the context:

ctx.cancelStep("awaitApproval");                            // default message: "Step cancelled"
ctx.cancelStep("awaitApproval", "Manager rejected");        // custom reason
ctx.cancelStep("awaitApproval", someException);              // wrap an existing exception

Both approaches produce a CANCELLED event in the event store:

1 cancelled AwaitApprovalCancelled
1 cancelled AwaitApprovalCancelled {"reason": "StepCancellationException: Manager rejected"}

Step cancellation only affects the individual step. The workflow keeps running—other steps are not touched. This is different from ctx.cancel() or ctx.fail(), which terminate the entire workflow and cancel all running steps.

Cancelling a step interrupts the running action by throwing a StepCancellationException. You can catch this in your action to perform cleanup before the step terminates.

The event name follows the step’s EventNameCustomizer. If the step was started with a custom namespace or base name, the CANCELLED event uses the same customization. See Step-level customizer for how to control event naming, including the CANCELLED suffix.

For workflow-level termination (ctx.fail(), ctx.cancel()), terminal states, unhandled exceptions, and lifecycle listeners (@OnSuccess, @OnFailure, etc.), see Workflow Lifecycle.

Execution semantics

Steps currently execute with at least once semantics. If the application crashes after a step’s action completes but before the COMPLETED event is persisted, the action may be re-executed on recovery.

Design your step actions to be idempotent—safe to run more than once with the same input.

Exactly once semantics are planned for a future version. The engine will use consistency markers to guarantee that each step action executes exactly once, even across crashes and replays.

Retries

The RetryPolicy API lets you configure retries per step—max attempts, backoff strategy, and programmatic control:

// Simple: retry up to 3 times (uses default timeout and event names)
ctx.awaitExecute("reserveInventory",
        payload("orderId", orderId).getValues(),
        InventoryService::reserve,
        RetryPolicy.maxRetries(3));                                       (1)

// With fixed backoff: wait 500ms between retries
ctx.awaitExecute("chargePayment",
        payload("orderId", orderId, "amount", amount).getValues(),
        PaymentGateway::charge,
        RetryPolicy.maxRetries(3)
                .withBackoff(BackoffStrategy.fixed(Duration.ofMillis(500))));

// With exponential backoff (base 200ms, capped at 5s)
ctx.awaitExecute("notifyCarrier",
        payload("shipmentId", shipmentId).getValues(),
        CarrierService::notify,
        RetryPolicy.maxRetries(5)
                .withBackoff(BackoffStrategy.exponential(Duration.ofMillis(200), Duration.ofSeconds(5))));

// With retry handler and conditional stop
ctx.awaitExecute("validateAddress",
        payload("address", address).getValues(),
        AddressService::validate,
        RetryPolicy.maxRetries(5)
                .onRetry(retryCtx -> logger.warn("Retry {}/{} for {}: {}",       (2)
                        retryCtx.attempt(), retryCtx.maxRetries(),
                        retryCtx.stepName(), retryCtx.error().getMessage()))
                .retryWhile(retryCtx -> isTransient(retryCtx.error())));          (3)
1 If the step fails, the engine retries up to 3 times before marking it as FAILED. maxRetries=3 means up to 4 total executions (1 initial + 3 retries).
2 onRetry is called before each retry—use it for logging, metrics, or alerts.
3 retryWhile continues retrying as long as the predicate returns true—here, only transient errors are retried (for example, a 404 is not retried).

The reserveInventory step with RetryPolicy.maxRetries(3)—fails twice, succeeds on the third attempt:

1 started ReserveInventoryStarted 2 retrying ReserveInventoryRetrying {"attempt": 1, "maxRetries": 3, "error": "InventoryUnavailableException"} 3 retrying ReserveInventoryRetrying {"attempt": 2, "maxRetries": 3, "error": "InventoryUnavailableException"} 4 completed ReserveInventoryCompleted {"reserved": true}

Backoff strategies

Strategy Behavior

BackoffStrategy.NONE

No delay between retries (default).

BackoffStrategy.fixed(Duration)

Constant delay between retries.

BackoffStrategy.linear(Duration base)

Delay increases linearly: base * attempt.

BackoffStrategy.exponential(Duration base, Duration max)

Delay doubles each attempt: base * 2^(attempt-1), capped at max.

Cancellation during retry

If a step throws StepCancellationException during any retry attempt, the step immediately transitions to CANCELLED—remaining retries are skipped. Cancellation always takes precedence over the retry policy. This also applies when the step is cancelled externally via ctx.cancelStep() or when the workflow itself is cancelled—any in-flight backoff delay is interrupted and the step moves to a terminal state.

Crash recovery

Retries are event-sourced—on crash recovery, the engine replays retry events and recalculates backoff from persisted timestamps. If the process restarts while a step is in RETRYING state, the engine resumes from the correct attempt number with the remaining backoff delay.

Event name customization

Every step produces events named [namespace].[BaseName][Suffix]. The EventNameCustomizer controls each part. You can set it at the workflow level (applies to all steps) or override it on individual steps.

Workflow-level customizer

The simplest way is via the @Workflow annotation:

@Workflow(idProperty = "orderId",
          startOnEvent = "OrderPlaced",
          workflowNamespace = "io.myapp.orders")  (1)
public void execute(SimpleWorkflowContext ctx) {
    ctx.awaitExecute("reserveStock", Boolean.class,
                      InventoryService::reserveStock); (2)
}
1 Sets the namespace for all events in this workflow.
2 This step’s events will be io.myapp.orders.ReserveStockStarted and io.myapp.orders.ReserveStockCompleted—no per-step configuration needed.

For more control (for example, custom suffixes), use the declarative configuration:

return d -> d
        .declarative(c -> workflow::execute)
        .workflowName("OrderFulfillment")
        .on(EventConditions.fromType(OrderPlacedEvent.class))
        .customized((c, w) -> w
                .eventNameCustomizer(
                        namespace("io.myapp.orders")
                                .stepCompleted("Done")
                                .workflowBaseName("OrderFulfillment"))
                .workflowIdProvider(fromPayloadAttribute(c, "orderId"))
        );

With this configuration, all steps produce events like io.myapp.orders.ReserveStockDone—without having to specify the namespace or suffix on every step.

For the full declarative configuration API—workflow modules, start conditions, ID providers, and more—see Configuration.

Step-level customizer

Override the customizer on a specific step when you need different naming:

ctx.awaitExecute("initiatePayment",
                 payload("amount", amount).getValues(),
                 PaymentService::initiatePayment,
                 Duration.ofSeconds(30),
                 namespace("io.myapp.payments")); (1)
1 This step’s events use io.myapp.payments while all other steps still use the workflow-level namespace.

Step-level customizers also control CANCELLED event names consistently. If a step uses a custom namespace, its cancellation event uses the same namespace:

ctx.execute("shipOrder", ctx.workflowPayload(),
        ShippingService::shipOrder,
        Duration.ofMinutes(5),
        namespace("io.shipping"));

// If cancelled, the event will be: io.shipping.ShipOrderCancelled

The CANCELLED suffix itself can also be customized via stepCancelled(…​):

defaults().stepCancelled("Aborted")
// produces: io.namespace.ShipOrderAborted

If you don’t pass a customizer (or pass defaults()), the step inherits from the workflow-level customizer.

Defaults and Inheritance

Axon Workflows defines sensible defaults for naming workflow events. The workflow-level customizer propagates selected properties to steps via forStepInheritance(). When a step also has its own customizer, the two are merged—step-level wins when it differs from defaults, otherwise the workflow-level value is used. The table below explains the naming settings defaults and inheritance in detail.

The workflow-level customizer propagates selected properties to steps via forStepInheritance(). When a step also has its own customizer, the two are merged—step-level wins when it differs from defaults, otherwise the workflow-level value is used.

Table 3. What steps inherit from the workflow customizer
Property Description Default Inherited?

namespace

The prefix for all event names (for example, io.myapp.orders)

io.axoniq.workflow

Yes—set it once, all steps use it

capitalizeSimpleName

Whether step names are capitalized in event names (reserveStockReserveStock)

true

Yes

Step status suffixes

The suffix appended for each status (stepStarted, stepCompleted, stepFailed, stepTimedOut, stepCancelled)

Started, Completed, Failed, TimedOut, Cancelled

Yes

baseName

Replaces the step name in the event name

The step name itself

No—each step uses its own step name

workflowBaseName

Base name for workflow-level events (for example, OrderFulfillmentWorkflow#ExecuteStarted, OrderFulfillmentWorkflow#ExecuteCompleted)

The workflow name

No—workflow-specific

appendToBaseName

Whether to include the step name before the suffix

true

No—resets to default per step

Workflow status suffixes

Suffixes for workflow-level events (workflowStarted, workflowCompleted, etc.)

Started, Completed, Failed, TimedOut, Cancelled

No—workflow-specific

payloadCustomization

Dynamic event naming based on payload content

None

No—workflow-specific

Example: Set the namespace and suffix globally, then override one step:

// Workflow-level: all steps use io.myapp namespace and "Done" suffix
.customized((c, w) -> w.eventNameCustomizer(
        namespace("io.myapp").stepCompleted("Done")
))
// Step 1: inherits workflow customizer — no override needed
ctx.awaitExecute("reserveStock", Boolean.class,
                  InventoryService::reserveStock);

// Step 2: overrides namespace for this step only
ctx.awaitExecute("initiatePayment",
                 payload("amount", amount).getValues(),
                 PaymentService::initiatePayment,
                 Duration.ofSeconds(30),
                 namespace("io.payments"));

Resulting events:

io.myapp.ReserveStockStarted
io.myapp.ReserveStockDone          (1)
io.payments.InitiatePaymentStarted (2)
io.payments.InitiatePaymentDone    (3)
1 Inherited io.myapp namespace and Done suffix from the workflow customizer.
2 Step-level namespace("io.payments") overrides the workflow namespace.
3 The Done suffix is still inherited—only the namespace was overridden.

Builder methods

These static methods from DefaultEventNameCustomizer.Builder create a customizer with one option changed:

Builder method Effect Example result

defaults()

Default naming: namespace io.axoniq.workflow, capitalized step name + suffix

io.axoniq.workflow.ReserveStockCompleted

namespace("io.payments")

Changes the namespace prefix

io.payments.ReserveStockCompleted

baseName("ProcessPayment")

Replaces the step name in the event name

io.axoniq.workflow.ProcessPaymentCompleted

capitalizeSimpleName(false)

Keeps the step name lowercase

io.axoniq.workflow.reserveStockCompleted

appendToBaseName(false)

Omits the step name entirely, leaving only the suffix

io.axoniq.workflow.Completed

All builders return a DefaultEventNameCustomizer that supports fluent chaining:

namespace("io.myapp").stepCompleted("Done").stepFailed("Errored")

Customizing suffixes

You can rename the status suffixes for steps:

defaults()
    .stepStarted("Initiated")
    .stepCompleted("Done")
    .stepFailed("Errored")
    .stepTimedOut("Expired")
    .stepCancelled("Aborted")

With stepName = "payment", this produces events like PaymentInitiated, PaymentDone, PaymentErrored, etc.

Workflow-level suffixes can be customized separately via workflowStarted(…​), workflowCompleted(…​), workflowFailed(…​), workflowTimedOut(…​), and workflowCancelled(…​).

Payload-based customization

For dynamic event names based on payload content:

payloadCustomization(pc -> {
    var priority = pc.payload().getOrDefault("priority", "normal");
    return new QualifiedName(pc.namespaceTemplate(),
                             priority + pc.localNameTemplate());
})

This would produce PriorityPaymentCompleted or NormalPaymentCompleted depending on the payload.

Kotlin DSL equivalents

The Kotlin DSL exposes the same options as named parameters:

// Non-blocking execute with all options
execute(
    "shipOrder",
    action = { pc, p -> ShippingService.shipOrder(pc, p) },
    local = mapOf("address" to address),
    parameterMapping = PayloadReducer.LOCAL_ONLY,
    resultMapping = PayloadReducer.GLOBAL_ONLY,
    timeout = 5.minutes,
    eventNameCustomizer = namespace("io.shipping")
)

// Blocking awaitExecute with all options
val result = awaitExecute(
    "reserveStock",
    action = { InventoryService.reserveStock() },
    local = mapOf("customerId" to customerId),
    timeout = 10.seconds,
    eventNameCustomizer = baseName("ReservingStock")
)

Kotlin’s named parameters and default values mean you only specify what you need—everything else falls back to sensible defaults.