Your First Workflow
A workflow definition is just imperative Java code. You write the business logic; the engine records every step as a durable event. If the application crashes, the engine replays those events and resumes exactly where it left off.
Defining a workflow
Here’s the simplest possible workflow—a single durable step:
public class OrderFulfillmentWorkflow {
@Workflow(idProperty = "orderId", startOnEvent = "OrderPlaced")
public void execute(SimpleWorkflowContext ctx) {
ctx.awaitExecute("reserveStock", Boolean.class,
InventoryService::reserveStock); (1)
}
}
| 1 | awaitExecute calls the action, waits for it to finish, and returns the typed result.
The engine records a STARTED and COMPLETED event for this step automatically. |
That’s it—one line of business logic, and the engine gives you event sourcing, crash recovery, and an audit trail.
Now let’s make it more realistic by handling the result and passing payload:
public class OrderFulfillmentWorkflow {
Logger logger = LoggerFactory.getLogger(OrderFulfillmentWorkflow.class);
@Workflow(idProperty = "orderId", startOnEvent = "OrderPlaced")
public void execute(SimpleWorkflowContext ctx) {
var customerId = ctx.workflowPayload().get("customerId");
var amount = ctx.workflowPayload().get("amount");
var reserved = ctx.awaitExecute("reserveStock",
payload("customerId", customerId, "amount", amount).getValues(),
Boolean.class,
InventoryService::reserveStock); (1)
if (!reserved) {
ctx.fail(new RuntimeException("Stock unavailable")); (2)
return;
}
logger.info("Stock reserved for order {}", ctx.workflowPayload().get("orderId"));
}
}
| 1 | awaitExecute runs the action synchronously and returns the typed result.
The params (payload) passed to the step (customerId, amount) are recorded in the STARTED event. |
| 2 | If stock reservation fails, we fail the workflow.
Standard Java control flow—if, loops, try/catch—all work exactly as you’d expect. |
When this workflow executes successfully, the engine records the following events in the event store:
The triggering OrderPlaced event’s orderId becomes the workflow instance ID (from idProperty).
Each awaitExecute produces a STARTED/COMPLETED pair—the STARTED event captures the input payload, the COMPLETED event captures the result.
The __reserveStock key stores the typed return value (true).
Every primitive call becomes a pair of durable events. This is what gives you audit trails, crash recovery, and replay—with zero extra code.
The @Workflow annotation
The @Workflow annotation tells the engine how to wire up your workflow:
idProperty = "orderId"
|
Extracts the workflow instance ID from the triggering event’s payload.
Each unique |
startOnEvent = "OrderPlaced"
|
The qualified event name that triggers a new workflow instance. |
|
You can also use |
What happens under the hood
Each awaitExecute call produces two durable events: a STARTED event when the step begins, and a COMPLETED (or FAILED, TIMED_OUT) event when it finishes.
The engine records everything. On crash recovery, it replays those events and resumes execution from where it left off—no manual state management needed.
|
You write simple imperative code. The engine translates each primitive call into durable events. This gives you event sourcing, audit trails, and crash recovery without additional effort. |
Adding more steps
Let’s extend our workflow to initiate payment after reserving stock:
@Workflow(idProperty = "orderId", startOnEvent = "OrderPlaced")
public void execute(SimpleWorkflowContext ctx) {
var customerId = ctx.workflowPayload().get("customerId");
var amount = ctx.workflowPayload().get("amount");
var reserved = ctx.awaitExecute("reserveStock",
payload("customerId", customerId, "amount", amount).getValues(),
Boolean.class,
InventoryService::reserveStock);
if (!reserved) {
ctx.fail(new RuntimeException("Stock unavailable"));
return;
}
ctx.awaitExecute("initiatePayment", (1)
payload("customerId", customerId,
"amount", amount).getValues(),
PaymentService::initiatePayment,
Duration.ofSeconds(30),
baseName("InitiatingPaymentForCustomer")); (2)
}
| 1 | A second step that initiates payment processing, passing customerId and amount as input. |
| 2 | baseName(…) replaces the step name in the event name—its events will appear as InitiatingPaymentForCustomerStarted instead of InitiatePaymentStarted. |
Here’s the event store after a successful run:
Notice how baseName("InitiatingPaymentForCustomer") changed the event name from the default InitiatePayment to InitiatingPaymentForCustomer.
Waiting for an event
Our workflow initiates payment, but how do we know when it’s confirmed?
The awaitEvent primitive suspends the workflow until a matching external event arrives:
@Workflow(idProperty = "orderId", startOnEvent = "OrderPlaced")
public void execute(SimpleWorkflowContext ctx) {
var customerId = ctx.workflowPayload().get("customerId");
var amount = ctx.workflowPayload().get("amount");
var reserved = ctx.awaitExecute("reserveStock",
payload("customerId", customerId, "amount", amount).getValues(),
Boolean.class,
InventoryService::reserveStock);
if (!reserved) {
ctx.fail(new RuntimeException("Stock unavailable"));
return;
}
ctx.awaitExecute("initiatePayment",
payload("customerId", customerId,
"amount", amount).getValues(),
PaymentService::initiatePayment,
Duration.ofSeconds(30),
baseName("InitiatingPaymentForCustomer"));
var confirmation = ctx.awaitEvent("awaitPayment", (1)
PaymentConfirmed.class,
Duration.ofMinutes(15)); (2)
logger.info("Payment confirmed: {}", confirmation);
}
| 1 | awaitEvent blocks the workflow until a PaymentConfirmed event arrives.
The engine subscribes to the event bus and watches for events of this type. |
| 2 | Maximum time to wait. After 15 minutes, the step completes with StepStatus.TIMED_OUT. |
When the PaymentConfirmed event arrives, the workflow resumes and awaitEvent returns the deserialized event object.
Here’s the full event store:
The AwaitPaymentStarted event records when the wait began.
When the external PaymentConfirmed event arrives, it unblocks the workflow and the matched payload is recorded in AwaitPaymentCompleted.
|
The events that |
Kotlin DSL
If you prefer Kotlin, the same workflow looks like this:
class OrderFulfillmentWorkflow {
@Workflow(idProperty = "orderId", startOnEvent = "OrderPlaced")
fun Kontext.onExecute() {
val customerId = payload["customerId"]
val amount = payload["amount"]
val reserved = awaitExecute(
"reserveStock",
{ InventoryService.reserveStock() },
local = mapOf("customerId" to customerId, "amount" to amount)
)
if (!reserved) {
fail(RuntimeException("Stock unavailable"))
return
}
awaitExecute(
"initiatePayment",
{ PaymentService.initiatePayment() },
local = mapOf("customerId" to customerId, "amount" to amount),
timeout = 30.seconds,
eventNameCustomizer = baseName("InitiatingPaymentForCustomer")
)
val confirmation = awaitEvent<PaymentConfirmed>(
"awaitPayment",
timeout = 15.minutes
)
logger.info("Payment confirmed: {}", confirmation)
}
}
The Kotlin DSL uses extension functions on Kontext, giving you a clean, idiomatic API with named parameters and kotlin.time.Duration support (for example, 30.seconds).
That’s a complete workflow—it executes actions and waits for external events. To learn more about all execution options, event associations, orchestration patterns, and advanced configuration, see the Reference Guide.