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
|
Step primitives at a glance
| Primitive | Blocking | Returns | Covered in |
|---|---|---|---|
|
Yes |
Typed result |
|
|
No |
|
|
|
Yes |
Typed event |
|
|
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,
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 |
| Override per step |
Pass a |
// 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: |
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) |
|---|---|---|
|
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 (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 |
|
Step times out |
|
Step is cancelled |
|
Workflow is failed via |
|
Workflow is cancelled via |
|
|
Any exception thrown during step execution is wrapped in |
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:
|
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 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:
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 |
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.
| 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", 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 |
|---|---|---|
|
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 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.