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, and execution semantics.
|
Everything in this section applies to all step primitives—unless noted otherwise. |
|
Primitives cannot be nested.
Do not call
|
Step primitives at a glance
| Primitive | Blocking | Returns | Covered in |
|---|---|---|---|
|
Yes |
Typed result |
|
|
No |
|
|
|
Yes |
Typed event |
|
|
No |
|
|
|
Yes |
void |
|
|
No |
|
|
|
Yes |
void |
|
|
No |
|
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.
| Method | Description |
|---|---|
|
Blocks until the step finishes. Use when you don’t need the outcome, just the guarantee it’s done. |
|
Blocks, then returns |
|
Blocks, then returns |
|
Blocks, then returns |
|
Blocks, then returns |
| Method | Description |
|---|---|
|
Returns the step name. |
|
Returns |
|
Returns |
|
Returns |
|
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,
step -> step.timeout(Duration.ofMinutes(5)));
// non-blocking wait for event
var approval = ctx.waitForEvent("awaitApproval",
EventConditions.fromQualifiedName(ctx.resolve(ManagerApproved.class)),
step -> step.timeout(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 |
| Override per step |
Customize the step definition with |
// Uses the default 5-second timeout
ctx.awaitExecute("quickCheck",
Map.of(),
(pc, input) -> Map.of("passed", QuickService::check()));
// Explicit 30-second timeout for an action
ctx.awaitExecute("slowOperation", ctx.workflowPayload(),
SlowService::process,
step -> step.timeout(Duration.ofSeconds(30)));
// Explicit 15-minute timeout for waiting on an external event
ctx.awaitEvent("awaitPayment", PaymentConfirmed.class,
associate(payloadProperty("orderId"),
equalsTo(ctx.workflowPayload().get("orderId"))),
step -> step.timeout(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: |
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:
parameterPayloadReducer
|
Controls the input—what payload the step’s action receives. |
resultPayloadReducer
|
Controls the output—what happens to the step’s result after it completes. |
Available options
| Reducer | As input (parameterPayloadReducer) |
As output (resultPayloadReducer) |
|---|---|---|
|
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. |
|
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. |
|
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 (LocalOnlyPayloadReducer.INSTANCE for input and GlobalOnlyPayloadReducer.INSTANCE for 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. |
ctx.awaitExecute(
"processOrder",
payload("priority", "high").getValues(),
OrderService::process,
step -> step.parameterPayloadReducer(CombineGlobalAndLocalPayloadReducer.INSTANCE)
.resultPayloadReducer(CombineGlobalAndLocalPayloadReducer.INSTANCE)
);
The action receives the global payload merged with the local priority field, and 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 LocalOnlyPayloadReducer.INSTANCE for input and GlobalOnlyPayloadReducer.INSTANCE for output by default.
Error handling
Every step failure surfaces as a StepFailedException or one of its subtypes. Catch it to compensate, or
let it propagate to fail the workflow. What the engine stores about an error, when a workflow stops for good
and when it pauses and resumes are covered in one place: Error Handling.
Cancelling a step
You can cancel a running step in two ways:
Via the result handle:
var approval = ctx.waitForEvent("awaitApproval",
EventConditions.fromQualifiedName(ctx.resolve(ManagerApproved.class)),
step -> step.timeout(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",
step -> step.cause(new StepCancellationException("Manager rejected"))); // custom reason
ctx.cancelStep("awaitApproval",
step -> step.cause(someException)); // wrap an existing exception
Both approaches produce a CANCELLED event in the event store:
An external caller can request the same single-step cancellation through WorkflowManager when the workflow is live:
workflowManager.findOne(WorkflowStateQuery.byWorkflowId(orderId))
.requestStepCancellation("awaitApproval", null);
The returned CompletableFuture<Boolean> is true when the terminal cancellation was recorded. See
Managing Workflow Instances for outside-in cancellation and instance lookup.
|
Step cancellation only affects the individual step.
The workflow keeps running—other steps are not touched.
This is different from |
|
Cancelling a step interrupts the running action by throwing a |
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 ( |
Execution semantics
|
Steps execute with at most once semantics. A step’s action runs at most once across the whole workflow lifetime, including across crashes and replays — it never runs twice. The action runs before its COMPLETED event is persisted. If the application crashes in that window (action ran, COMPLETED not yet committed), the engine does not re-run the action on recovery: the durable STARTED record means an attempt was already opened and may have run, and re-running it would break the at-most-once guarantee. Because the outcome of the interrupted attempt is unknown, the engine resolves it through the regular error flow instead of re-executing it (see below). The trade-off of at-most-once is that an interrupted action may run zero or one times — it may not run at all. If a step’s effect must eventually happen, make it idempotent and configure a |
Crash-interrupted steps: which error, and how to retry
When a step’s action is interrupted by a crash and not re-run, the engine resolves it exactly like any other failed attempt:
-
No retry policy → the step records a terminal FAILED status. The failure is surfaced to your workflow body as a
StepFailedExceptionwhose cause is aStepIndeterminateException(the dedicated "in-flight at a crash, not re-run, outcome indeterminate" marker).awaitExecute(…)throws it; a non-blockingexecute(…)handle reports it viaresult.failure()/result.error(). -
With a retry policy → the interrupted attempt is treated as a failed attempt and the step goes to RETRYING; the engine schedules the next attempt per the policy (backoff included). This is identical to the normal retry-on-failure flow, so a step you want re-attempted after a crash should carry a
RetryPolicy(and an idempotent action, since a retry runs the effect again). Each retry attempt records RETRY_STARTED before it runs, and runs only if the event store accepted that record. A node that lost the workflow instance during the backoff is rejected there and never runs the action.
|
A step reaching FAILED does not by itself terminate the workflow while the body handles it. Catch the
A |
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.
Every retry attempt records RetryStarted before its action runs; attempt on that event is the attempt being started:
Backoff strategies
| Strategy | Behavior |
|---|---|
|
No delay between retries (default). |
|
Constant delay between retries. |
|
Delay increases linearly: |
|
Delay doubles each attempt: |
Cancellation during retry
|
If a step throws |
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.
If it restarts while a step is in RETRY_STARTED state, that attempt was running at the crash. It is treated as a failed attempt with the recorded attempt number, so the step goes to RETRYING and the retry count is not reset.
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",
startOnEventClass = OrderPlacedEvent.class,
workflowNamespace = "io.myapp.orders") (1)
public void execute(SimpleWorkflowContext ctx) {
ctx.awaitExecute("reserveStock",
Map.of(),
(pc, input) -> Map.of("reserved", 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.
| Property | Description | Default | Inherited? |
|---|---|---|---|
|
The prefix for all event names (for example, |
|
Yes—set it once, all steps use it |
|
Whether step names are capitalized in event names ( |
|
Yes |
Step status suffixes |
The suffix appended for each status ( |
|
Yes |
|
Replaces the step name in the event name |
The step name itself |
No—each step uses its own step name |
|
Base name for workflow-level events (for example, |
The workflow name |
No—workflow-specific |
|
Whether to include the step name before the suffix |
|
No—resets to default per step |
Workflow status suffixes |
Suffixes for workflow-level events ( |
|
No—workflow-specific |
|
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",
Map.of(),
(pc, input) -> Map.of("reserved", 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 |
|---|---|---|
|
Default naming: namespace |
|
|
Changes the namespace prefix |
|
|
Replaces the step name in the event name |
|
|
Keeps the step name lowercase |
|
|
Omits the step name entirely, leaving only the suffix |
|
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 concise overloads, while still allowing direct definition objects when needed:
val result = execute(
stepName = "shipOrder",
inputPayload = mapOf("address" to address),
timeout = 5.minutes,
retryPolicy = RetryPolicy.NONE
) { pc, p ->
ShippingService.shipOrder(pc, p)
}
val reservation = awaitExecute(
stepName = "reserveStock",
timeout = 10.seconds
) { _, _ ->
mapOf("reserved" to InventoryService.reserveStock())
}
For advanced cases, you can still pass ExecuteStepDefinition, WaitForStepDefinition, and PayloadStepDefinition directly to Kontext.
The convenience overloads simply build those same definitions with Kotlin defaults applied up front.
When you want to pass step-local data as a named argument in Kotlin, use inputPayload = … to match the Java DSL terminology.