Step Orchestration
Real-world workflows often need to run steps concurrently. You might want to ship the order and send a confirmation email at the same time, request shipping quotes from two carriers and take the fastest, or wait for multiple approvals before proceeding.
The three combinators—anyMatch, allMatch, and noneMatch—let you compose non-blocking steps (execute and waitForEvent) into powerful orchestration patterns.
Non-blocking execution
So far we’ve used awaitExecute and awaitEvent, which block until the step completes.
The non-blocking variants—execute and waitForEvent—start steps without waiting and return a WorkflowStepResult:
var shipping = ctx.execute("shipOrder", ctx.workflowPayload(),
ShippingService::shipOrder,
Duration.ofMinutes(5), defaults());
var notification = ctx.execute("notifyCustomer", ctx.workflowPayload(),
NotificationService::sendConfirmation,
Duration.ofSeconds(30), defaults());
Both steps run concurrently. You can also mix execute with waitForEvent:
var approval = ctx.waitForEvent("awaitApproval",
ManagerApproved.class, Duration.ofMinutes(30));
var backgroundCheck = ctx.execute("runBackgroundCheck", ctx.workflowPayload(),
ComplianceService::check,
Duration.ofMinutes(10), defaults());
anyMatch—first match wins
anyMatch resolves as soon as the first step reaches a terminal state that matches the predicate.
|
|
Race two events
Wait for the first of two possible outcomes—whichever event arrives first wins:
var accepted = ctx.waitForEvent("paymentAccepted",
CardPaymentAccepted.class, Duration.ofMinutes(10));
var declined = ctx.waitForEvent("paymentDeclined",
CardPaymentDeclined.class, Duration.ofMinutes(10));
var result = ctx.anyMatch(WorkflowStepResult::isCompleted, (1)
accepted, declined);
result.await(); (2)
var winner = result.matched().getFirst(); (3)
logger.info("Payment outcome: {}", winner.getStepName());
// Cancel the wait for the other event
for (var remaining : result.unmatched()) { (4)
remaining.cancel("Outcome already determined");
}
if (winner == declined) {
ctx.fail(new RuntimeException("Payment was declined"));
}
| 1 | Resolves when the first result reaches a terminal state matching the predicate. |
| 2 | Blocks until the outcome is determined. |
| 3 | matched() contains the event that arrived first—the winner is always the first element. |
| 4 | unmatched() contains the other wait—cancel it since we already have our answer. |
Race two services
Same pattern works with execute—request shipping quotes from two carriers, take whichever responds first:
var dhlQuote = ctx.execute("getDhlQuote", ctx.workflowPayload(),
DhlService::getQuote, Duration.ofSeconds(10), defaults());
var fedexQuote = ctx.execute("getFedexQuote", ctx.workflowPayload(),
FedexService::getQuote, Duration.ofSeconds(10), defaults());
var race = ctx.anyMatch(WorkflowStepResult::isCompleted,
dhlQuote, fedexQuote);
race.await();
var winner = race.matched().getFirst();
logger.info("Using {} — quoted first", winner.getStepName());
// Cancel the slower carrier
for (var loser : race.unmatched()) {
loser.cancel("Went with " + winner.getStepName());
}
allMatch—all must succeed
allMatch succeeds only when all steps match the predicate.
It short-circuits on the first non-match:
// Ship and notify in parallel
var shipping = ctx.execute("shipOrder", ctx.workflowPayload(),
ShippingService::shipOrder,
Duration.ofMinutes(5), defaults());
var notification = ctx.execute("notifyCustomer", ctx.workflowPayload(),
NotificationService::sendConfirmation,
Duration.ofSeconds(30), defaults());
var guard = ctx.allMatch(WorkflowStepResult::success, (1)
shipping, notification);
if (guard.success()) { (2)
logger.info("All steps completed successfully!");
} else {
// Something failed — inspect what went wrong
for (var failed : guard.unmatched()) { (3)
logger.warn("Step {} did not succeed", failed.getStepName());
}
ctx.fail(new RuntimeException("Not all steps completed successfully"));
}
| 1 | Blocks until all steps complete successfully, or one fails (short-circuit). |
| 2 | success() on the combinator is true only if all steps matched the predicate. |
| 3 | unmatched() contains the step that violated the predicate, plus any steps that hadn’t completed yet when the short-circuit happened. |
noneMatch—no failures allowed
noneMatch succeeds when no step matches the predicate.
|
|
Basic usage
var stepA = ctx.execute("processA", Map.of(), ServiceA::process,
Duration.ofMinutes(5), defaults());
var stepB = ctx.execute("processB", Map.of(), ServiceB::process,
Duration.ofMinutes(5), defaults());
var stepC = ctx.execute("processC", Map.of(), ServiceC::process,
Duration.ofMinutes(5), defaults());
var check = ctx.noneMatch(WorkflowStepResult::failure, (1)
stepA, stepB, stepC);
if (check.success()) {
logger.info("No failures — all clear!");
}
| 1 | Succeeds if none of the steps fail. Short-circuits as soon as one fails. When success() returns true, all steps have reached a terminal state—no cancellation is needed. |
Cancel remaining on failure
When one step fails, cancel the others immediately:
var check = ctx.noneMatch(WorkflowStepResult::failure,
stepA, stepB, stepC);
check.await();
if (!check.success()) {
var failedStep = check.matched().getFirst(); (1)
logger.warn("Step {} failed", failedStep.getStepName());
// Cancel all steps that are still running
for (var remaining : check.unmatched()) { (2)
remaining.cancel("Cancelled due to failure of " + failedStep.getStepName());
}
ctx.fail(new RuntimeException("Step " + failedStep.getStepName() + " failed"));
}
| 1 | matched() contains the steps that triggered the predicate—the violators. |
| 2 | unmatched() contains the steps that didn’t fail—some may still be running. |
Compensating actions on failure
For a more robust pattern, cancel remaining steps and run a compensating action to roll back:
var reserveStock = ctx.execute("reserveStock", ctx.workflowPayload(),
InventoryService::reserveStock,
Duration.ofMinutes(1), defaults());
var reserveShipping = ctx.execute("reserveShipping", ctx.workflowPayload(),
ShippingService::reserveSlot,
Duration.ofMinutes(1), defaults());
var chargePayment = ctx.execute("chargePayment", ctx.workflowPayload(),
PaymentService::charge,
Duration.ofSeconds(30), defaults());
var check = ctx.noneMatch(WorkflowStepResult::failure,
reserveStock, reserveShipping, chargePayment);
check.await();
if (!check.success()) {
var failedStep = check.matched().getFirst();
// 1. Cancel steps that are still running
for (var remaining : check.unmatched()) {
if (!remaining.isCompleted()) {
remaining.cancel("Rolling back due to " + failedStep.getStepName());
}
}
// 2. Compensate steps that already completed successfully
for (var completed : check.unmatched()) { (1)
if (completed.success()) {
ctx.awaitExecute("rollback-" + completed.getStepName(),
ctx.workflowPayload(),
CompensationService::rollback);
}
}
ctx.fail(new RuntimeException("Order failed at " + failedStep.getStepName()
+ ": " + failedStep.error().map(Throwable::getMessage).orElse("unknown")));
}
| 1 | Iterate over unmatched()—steps that didn’t fail. If they already completed successfully, run a compensating action to undo their work. |
Inspecting sub-results
All three combinators return a CombinatorWorkflowStepResult with matched() and unmatched() methods:
| Method | Description |
|---|---|
|
Steps that satisfied the predicate. Sorted by event-sourced timestamps (earliest first). For |
|
Everything else—steps that didn’t match, plus steps that hadn’t completed when the combinator short-circuited. Completed results appear first (sorted by timestamp), followed by still-running results. |
|
|
|
Sub-results are ordered by event-sourced timestamps, not array order. This ensures deterministic behavior during replay. |
Cleanup pattern with matched() and unmatched()
After a combinator short-circuits, use matched() to identify what triggered it and unmatched() to clean up everything else:
| Combinator | matched() contains |
unmatched() contains |
|---|---|---|
|
The winners—steps that satisfied the predicate |
The losers—cancel them if no longer needed |
|
The violators—steps that triggered the predicate (for example, the failed step) |
The rest—some may still be running, cancel them; some may have completed successfully, compensate them |
|
The steps that satisfied the predicate so far |
The violator that broke the guard, plus any still-running steps |
The general pattern after a short-circuit:
result.await();
if (!result.success()) {
// 1. Who caused the problem?
var trigger = result.matched().getFirst(); // for noneMatch: the violator
// for anyMatch: the winner
// 2. Clean up the rest
for (var step : result.unmatched()) {
if (!step.isCompleted()) {
step.cancel("No longer needed — " + trigger.getStepName() + " already resolved"); // still running → cancel
} else if (step.success()) {
// already completed → compensate if needed
}
}
}
Parallel workflows
Multiple workflows can be triggered by the same event, each handling a different concern independently.
When an OrderPlaced event is published, both workflows start in parallel—each with its own state and lifecycle.
Workflow IDs must be unique across all running workflows. If two workflow definitions derive the same ID from the triggering event, only the first one starts—the second is ignored and a warning is logged. To run genuinely parallel workflows on the same event, associate each with a different idProperty:
@Workflow(idProperty = "orderId", startOnEvent = "OrderPlaced")
public void execute(SimpleWorkflowContext ctx) {
ctx.awaitExecute("reserveStock", Boolean.class,
InventoryService::reserveStock);
ctx.awaitExecute("shipOrder", ctx.workflowPayload(),
ShippingService::shipOrder);
}
@Workflow(idProperty = "customerId", startOnEvent = "OrderPlaced")
public void execute(SimpleWorkflowContext ctx) {
ctx.awaitExecute("sendConfirmationEmail",
payload("email", ctx.workflowPayload().get("email"),
"orderId", ctx.workflowId()).getValues(),
EmailService::sendOrderConfirmation);
ctx.sleep("waitBeforeFollowUp", Duration.ofDays(3));
ctx.awaitExecute("sendFollowUpEmail",
payload("email", ctx.workflowPayload().get("email"),
"orderId", ctx.workflowId()).getValues(),
EmailService::sendFollowUp);
}
Both workflows receive the same OrderPlaced event payload, but they run independently—each has its own workflow instance, event stream, and lifecycle. If one fails, the other is unaffected. Because each workflow binds to a different ID property (orderId vs. customerId), both start independently even though they react to the same event.
For cross-workflow interaction patterns—parent-child workflows, event-based signalling between workflows—see Sub-workflows in Common Patterns.
For more orchestration patterns like fan-out/fan-in, saga compensation, scatter-gather, and human-in-the-loop, see Common Patterns.