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,
                Duration.ofSeconds(30), defaults()))
        .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)
                ApprovalDecisionEvent.class,
                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,
                Duration.ofSeconds(30), defaults()))
        .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",
        ApprovalDecisionEvent.class, 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",
            ApprovalDecisionEvent.class, 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 coming soon

First-class sub-workflow support with built-in lifecycle management is coming soon.

In the meantime, the engine’s event-sourced nature makes it straightforward to implement sub-workflows yourself. The trick: inside an awaitExecute action, publish a dedicated event that the engine routes through normal processing, which triggers the child workflow.

The same approach works in reverse: the child publishes a dedicated completion event in a final step, and the parent matches it with payloadProperty on a shared correlation ID (for example, orderId).

Parent workflow—launches a child and waits for it to finish
@Workflow(idProperty = "orderId",
          startOnEvent = "OrderPlaced",
          workflowNamespace = "io.myapp")
public void execute(SimpleWorkflowContext ctx) {

    // ... do some work ...

    var childWorkflowId = "payment-" + ctx.workflowId();

    // Launch the child workflow by publishing a dedicated event via EventSink.
    ctx.awaitExecute("startPaymentProcess",                                (1)
            payload("childWorkflowId", childWorkflowId,
                    "orderId", ctx.workflowId(),
                    "amount", ctx.workflowPayload().get("amount")).getValues(),
            (pc, p) -> {                                                   (2)
                var trigger = new StartPaymentProcess(childWorkflowId,
                        (String) p.get("orderId"),
                        ((Number) p.get("amount")).doubleValue());
                eventSink.publish(null, new GenericEventMessage(
                        messageTypeResolver.resolveOrThrow(trigger), trigger));
                return Map.of();
            });

    // ... do other work in parallel while child runs ...

    // Wait for the child's explicit completion event (matched by orderId in payload)
    ctx.awaitEvent("awaitPaymentProcess",                                  (3)
            PaymentProcessCompleted.class,
            associate(payloadProperty("orderId"),                           (4)
                      equalsTo(ctx.workflowId())),
            Duration.ofMinutes(30));

    logger.info("Child workflow completed for order {}", ctx.workflowId());
}
1 The step name startPaymentProcess produces a durable io.myapp.StartPaymentProcessStarted event in the parent’s stream for auditability.
2 Inside the action, a dedicated StartPaymentProcess event is published via EventSink with null ProcessingContext—no workflow metadata attached, so it triggers the child workflow.
3 The parent waits for PaymentProcessCompleted—a dedicated event the child publishes explicitly (not an auto-generated lifecycle event).
4 payloadProperty("orderId") matches on a field in the event payload. This works across workflow boundaries because the event carries no workflow metadata.
Child workflow—triggered by the dedicated event, signals completion explicitly
@Workflow(idProperty = "childWorkflowId",
          startOnEvent = "io.myapp.StartPaymentProcess",                   (1)
          workflowNamespace = "io.myapp.payments")
public void execute(SimpleWorkflowContext ctx) {

    ctx.awaitExecute("validatePayment",
            ctx.workflowPayload(),
            PaymentService::validate);

    ctx.awaitExecute("chargeCustomer",
            ctx.workflowPayload(),
            PaymentService::charge);

    // Signal the parent explicitly via EventSink
    ctx.awaitExecute("notifyParent", ctx.workflowPayload(),               (2)
            (pc, p) -> {
                var done = new PaymentProcessCompleted(
                        (String) p.get("orderId"),
                        (String) p.get("childWorkflowId"));
                eventSink.publish(null, new GenericEventMessage(
                        messageTypeResolver.resolveOrThrow(done), done));
                return Map.of();
            });
}
1 The child workflow starts when the StartPaymentProcess event (published without workflow metadata) is processed. The childWorkflowId from the payload becomes this workflow’s instance ID.
2 A final notifyParent step publishes PaymentProcessCompleted via EventSink with null ProcessingContext—no workflow metadata, so it reaches the parent’s awaitEvent.

The event store shows the handoff:

1 started StartPaymentProcessStarted {"childWorkflowId": "payment-123", "orderId": 123, "amount": 99.95} 2 external StartPaymentProcess {"childWorkflowId": "payment-123", "orderId": 123, "amount": 99.95} 3 completed StartPaymentProcessCompleted 4 started AwaitPaymentProcessStarted {"startTime": "2026-03-26T10:00:01Z"} 12 external PaymentProcessCompleted {"orderId": 123, "childWorkflowId": "payment-123"} 13 completed AwaitPaymentProcessCompleted 14 completed OrderFulfillmentWorkflow#ExecuteCompleted
2 external StartPaymentProcess {"childWorkflowId": "payment-123", "orderId": 123, "amount": 99.95} 5 started PaymentProcessWorkflow#ExecuteStarted 6 started ValidatePaymentStarted {"orderId": 123, "amount": 99.95} 7 completed ValidatePaymentCompleted 8 started ChargeCustomerStarted {"orderId": 123, "amount": 99.95} 9 completed ChargeCustomerCompleted 10 started NotifyParentStarted {"orderId": 123, "childWorkflowId": "payment-123"} 11 completed NotifyParentCompleted 12 completed PaymentProcessWorkflow#ExecuteCompleted

The parent’s startPaymentProcess action publishes StartPaymentProcess, which triggers the child workflow. The parent suspends at AwaitPaymentProcessStarted. The child runs its steps and, in its final notifyParent step, publishes PaymentProcessCompleted. The engine routes this event to the parent, where it matches the awaitEvent on payloadProperty("orderId").