Common Patterns
This section shows how to compose the engine’s primitives and combinators into common workflow patterns.
These aren’t special features—they’re built from execute, waitForEvent, awaitExecute, and the combinators you already know.
Fan-out / Fan-in
Fan-out launches multiple steps in parallel. Fan-in collects all results before continuing.
Fan-out—launch parallel steps
Reserve stock for every line item in an order concurrently:
var lineItems = (List<Map<String, Object>>) ctx.workflowPayload().get("lineItems");
// Fan-out: launch one reservation per line item
var reservations = lineItems.stream()
.map(item -> ctx.execute(
"reserve-" + item.get("sku"), (1)
payload("sku", item.get("sku"),
"quantity", item.get("quantity")).getValues(),
InventoryService::reserveStock,
step -> step.timeout(Duration.ofSeconds(30))))
.toArray(WorkflowStepResult[]::new);
| 1 | Each step gets a unique name based on the SKU—the engine tracks them independently. |
Fan-in—collect all results
Use allMatch to wait for every reservation to complete:
// Fan-in: wait for all reservations
var guard = ctx.allMatch(WorkflowStepResult::isCompleted, reservations); (1)
if (!guard.success()) {
// Some reservations failed — cancel the rest and compensate
for (var step : guard.unmatched()) {
if (!step.isCompleted()) {
step.cancel("Partial reservation failure");
}
}
for (var step : guard.matched()) {
if (step.success()) {
ctx.awaitExecute("release-" + step.getStepName(),
step.<Map<String, Object>>result().orElse(Map.of()),
InventoryService::releaseStock);
}
}
ctx.fail(new RuntimeException("Not all items could be reserved"));
}
logger.info("All {} items reserved!", reservations.length);
| 1 | allMatch accepts a varargs array—works naturally with the collected results. |
Fan-out with waitForEvent
Same pattern works for events. Request approval from multiple approvers in parallel, wait for all:
var approvers = List.of("team-lead", "finance", "legal");
// Fan-out: send approval requests and wait for each response
for (var approver : approvers) {
ctx.awaitExecute("requestApproval-" + approver,
payload("approver", approver, "requestId", ctx.workflowId()).getValues(),
ApprovalService::sendRequest);
}
var approvals = approvers.stream()
.map(approver -> ctx.waitForEvent(
"approval-" + approver, (1)
EventConditions.fromQualifiedName(ctx.resolve(ApprovalDecisionEvent.class)),
step -> step.timeout(Duration.ofDays(5))))
.toArray(WorkflowStepResult[]::new);
// Fan-in: wait for all approvals
var result = ctx.allMatch(WorkflowStepResult::isCompleted, approvals);
if (!result.success()) {
ctx.fail(new RuntimeException("Not all approvals received within deadline"));
}
logger.info("All {} approvers responded!", approvers.size());
| 1 | Each approver gets their own waitForEvent—all run concurrently. |
Saga—compensating transactions
A saga executes steps sequentially and rolls back completed steps if a later step fails. This is standard imperative code—try each step, and on failure, compensate in reverse order:
// Step 1: reserve stock
ctx.awaitExecute("reserveStock",
payload("orderId", orderId).getValues(),
InventoryService::reserveStock);
// Step 2: charge payment
try {
ctx.awaitExecute("chargePayment",
payload("orderId", orderId, "amount", amount).getValues(),
PaymentService::charge);
} catch (StepFailedException e) {
// Payment failed — compensate step 1
ctx.awaitExecute("releaseStock",
payload("orderId", orderId).getValues(),
InventoryService::releaseStock);
ctx.fail(new RuntimeException("Payment failed: " + e.getMessage()));
}
// Step 3: ship order
try {
ctx.awaitExecute("shipOrder",
payload("orderId", orderId).getValues(),
ShippingService::shipOrder);
} catch (StepFailedException e) {
// Shipping failed — compensate steps 2 and 1 in reverse
ctx.awaitExecute("refundPayment",
payload("orderId", orderId, "amount", amount).getValues(),
PaymentService::refund);
ctx.awaitExecute("releaseStock",
payload("orderId", orderId).getValues(),
InventoryService::releaseStock);
ctx.fail(new RuntimeException("Shipping failed: " + e.getMessage()));
}
Each compensating action is itself a durable step—the engine records it, and crash recovery ensures the rollback completes even if the application restarts mid-compensation.
Scatter-gather
Scatter-gather fans out to multiple services and collects results as they arrive—without waiting for all.
Use anyMatch in a loop to process results one at a time:
var suppliers = List.of("supplierA", "supplierB", "supplierC");
// Scatter: request quotes from all suppliers
var quotes = suppliers.stream()
.map(s -> ctx.execute("getQuote-" + s,
payload("supplier", s, "itemId", itemId).getValues(),
QuoteService::requestQuote,
step -> step.timeout(Duration.ofSeconds(30))))
.toList();
var remaining = new ArrayList<>(quotes);
// Gather: process quotes as they arrive
while (!remaining.isEmpty()) {
var fastest = ctx.anyMatch(WorkflowStepResult::isCompleted,
remaining.toArray(WorkflowStepResult[]::new));
fastest.await();
var winner = fastest.matched().getFirst();
logger.info("Received quote from {}: {}", winner.getStepName(), winner.result());
remaining.remove(winner); (1)
}
| 1 | Remove the processed result and loop until all quotes are collected. |
This lets you react to each response immediately—for example, display partial results, make early decisions, or short-circuit once you have enough data.
Human-in-the-loop
Wait for a human action—an approval, a review, a manual data entry—with escalation if they don’t respond in time:
// Request approval from the team lead
ctx.awaitExecute("requestApproval",
payload("approver", "team-lead", "requestId", ctx.workflowId()).getValues(),
ApprovalService::sendRequest);
// Wait for their response
var decision = ctx.waitForEvent("awaitApproval",
EventConditions.fromQualifiedName(ctx.resolve(ApprovalDecisionEvent.class)),
step -> step.timeout(Duration.ofDays(3)));
if (decision.timeout()) { (1)
// Team lead didn't respond — escalate to department head
ctx.awaitExecute("escalate",
payload("approver", "department-head", "requestId", ctx.workflowId()).getValues(),
ApprovalService::escalate);
decision = ctx.waitForEvent("awaitEscalatedApproval",
EventConditions.fromQualifiedName(ctx.resolve(ApprovalDecisionEvent.class)),
step -> step.timeout(Duration.ofDays(1)));
if (decision.timeout()) {
ctx.fail(new RuntimeException("No approval received after escalation"));
}
}
decision.await();
logger.info("Approval decision received: {}", decision.result());
| 1 | timeout() blocks until the step completes, then returns true if it timed out—perfect for escalation logic. |
Circuit breaker
Skip a step entirely if it has been failing consistently. This is implemented in your action code, not in the engine—but the engine’s durable state makes it easy:
var failureCount = (int) ctx.workflowPayload().getOrDefault("emailFailures", 0);
if (failureCount < 3) { (1)
try {
ctx.awaitExecute("sendEmail",
payload("to", email, "subject", subject).getValues(),
EmailService::send);
ctx.setPayload("resetEmailFailures",
payload("emailFailures", 0)); (2)
} catch (StepFailedException e) {
ctx.setPayload("recordEmailFailure",
payload("emailFailures", failureCount + 1)); (3)
logger.warn("Email failed ({}/3), will skip next time", failureCount + 1);
}
} else {
logger.warn("Circuit open — skipping email, {} consecutive failures", failureCount);
}
| 1 | Only attempt the step if under the failure threshold. |
| 2 | Reset the counter on success. |
| 3 | Increment the counter on failure—persisted durably in the workflow payload. |
Scheduled / delayed steps coming soon
|
Dedicated scheduling support is coming soon. In a future version, you will be able to schedule steps to run at a specific time (for example, "send reminder at 9 AM tomorrow"). |
For now, use sleep to implement delays:
// Wait until a specific time
var targetTime = LocalDateTime.of(2026, 4, 1, 9, 0);
var delay = Duration.between(LocalDateTime.now(), targetTime);
ctx.sleep("waitUntilAprilFirst", delay);
ctx.awaitExecute("sendReminder",
payload("to", email).getValues(),
ReminderService::send);
This is durable—if the application restarts during the sleep, the engine recalculates the remaining delay and resumes correctly.
Sub-workflows
Workflows talk to each other through published events.
A parent publishes an event with awaitPublish; a child workflow starts on it.
The child publishes its completion event the same way; the parent matches it with awaitEvent on a shared correlation ID (for example, orderId).
Each awaitPublish appends exactly one event: the business event itself, recorded as the publisher’s completed step.
Other workflows, entities and projections receive the event you published.
@Workflow(idProperty = "orderId",
startOnEventClass = OrderPlacedEvent.class,
workflowNamespace = "io.myapp")
public void execute(SimpleWorkflowContext workflowContext) {
// ... do some work ...
var orderId = (String) workflowContext.workflowPayload().get("orderId");
var amount = ((Number) workflowContext.workflowPayload().get("amount")).doubleValue();
// Register the wait for the child's completion BEFORE launching the child.
var completed = workflowContext.waitForEvent("awaitPaymentProcess", (1)
PaymentProcessCompleted.class,
associate(payloadProperty("orderId"),
equalsTo(orderId)), (2)
step -> step.timeout(Duration.ofMinutes(30)));
// Launch the child workflow by publishing a dedicated event.
workflowContext.awaitPublish("paymentProcessStarted", (3)
new PaymentProcessStarted("payment-" + orderId, orderId, amount));
// ... do other work in parallel while child runs ...
completed.await(); (4)
logger.info("Child workflow completed for order {}", orderId);
}
| 1 | The non-blocking waitForEvent registers the wait first.
A wait registered after the child’s reply has already passed is never woken, so registering it before the publish removes the race between parent and child. |
| 2 | payloadProperty("orderId") matches on a field in the event payload.
A published event is routed like any business event, so it reaches a waiting workflow on any segment. |
| 3 | The step name paymentProcessStarted is the durable identifier of the publish step.
The published PaymentProcessStarted event is the step’s record: one event, carrying the parent’s workflow metadata, that also starts the child. |
| 4 | Block on the handle when the parent needs the result.
ctx.allMatch(…) works too when several children are in flight. |
@Workflow(idProperty = "childWorkflowId",
startOnEventClass = PaymentProcessStarted.class, (1)
workflowNamespace = "io.myapp.payments")
public void execute(SimpleWorkflowContext workflowContext) {
workflowContext.awaitExecute("validatePayment",
workflowContext.workflowPayload(),
PaymentService::validate);
workflowContext.awaitExecute("chargeCustomer",
workflowContext.workflowPayload(),
PaymentService::charge);
// Signal the parent explicitly
workflowContext.awaitPublish("notifyParent", (2)
new PaymentProcessCompleted(
(String) workflowContext.workflowPayload().get("orderId"),
(String) workflowContext.workflowPayload().get("childWorkflowId")));
}
| 1 | The child starts when the PaymentProcessStarted event the parent published is processed.
The childWorkflowId from the payload becomes this workflow’s instance ID; it must differ from the parent’s ID, because a start whose ID already exists is rejected. |
| 2 | The final notifyParent step publishes PaymentProcessCompleted, which wakes the parent’s awaitEvent. |
The event store shows the handoff:
The parent registers its wait (AwaitPaymentProcessStarted) and then publishes: its paymentProcessStarted step is the PaymentProcessStarted event itself, one record in the parent’s stream and the trigger of the child.
The child runs its steps and its final notifyParent step is the PaymentProcessCompleted event.
The engine routes that event to the parent, where it matches the awaitEvent on payloadProperty("orderId").
|
Several running workflows can wait for the same published event.
One |