Skip to content

Durable Functions: Advanced Patterns

This recipe covers advanced Durable Functions patterns for Java beyond the basic chaining and fan-out/fan-in flows: sub-orchestrations, eternal orchestrations, activity retries, and safe versioning. For the fundamentals, see Durable Orchestration.

Architecture

flowchart TD
    PARENT[Parent orchestrator] -->|callSubOrchestrator| SUB1[Sub-orchestrator A]
    PARENT -->|callSubOrchestrator| SUB2[Sub-orchestrator B]
    SUB1 --> ACT1[Activities]
    SUB2 --> ACT2[Activities]
    SUB1 --> PARENT
    SUB2 --> PARENT

Sub-Orchestrations

Break a large workflow into reusable orchestrators. A parent calls a sub-orchestrator with ctx.callSubOrchestrator, and can fan out over several the same way it fans out over activities.

@FunctionName("ParentOrchestrator")
public String parentOrchestrator(
        @DurableOrchestrationTrigger(name = "ctx") TaskOrchestrationContext ctx) {
    List<String> regions = ctx.getInput(List.class);

    // Fan out over sub-orchestrations, one per region.
    List<Task<String>> tasks = new ArrayList<>();
    for (String region : regions) {
        tasks.add(ctx.callSubOrchestrator("ProcessRegion", region, String.class));
    }
    List<String> results = ctx.allOf(tasks).await();
    return "Processed " + results.size() + " regions";
}

@FunctionName("ProcessRegion")
public String processRegion(
        @DurableOrchestrationTrigger(name = "ctx") TaskOrchestrationContext ctx) {
    String region = ctx.getInput(String.class);
    String validated = ctx.callActivity("ValidateRegion", region, String.class).await();
    return ctx.callActivity("LoadRegion", validated, String.class).await();
}

Eternal Orchestrations

For a workflow that runs indefinitely (aggregators, periodic jobs), do not use an unbounded loop — the history would grow forever. Call ctx.continueAsNew to restart the orchestration with fresh state and a clean history.

@FunctionName("PeriodicCleanup")
public void periodicCleanup(
        @DurableOrchestrationTrigger(name = "ctx") TaskOrchestrationContext ctx) {
    CleanupState state = ctx.getInput(CleanupState.class);
    if (state == null) {
        state = new CleanupState();
    }

    ctx.callActivity("RunCleanup", state).await();
    state.runs += 1;

    // Durable sleep, then restart with new state and empty history.
    ctx.createTimer(Duration.ofHours(1)).await();
    ctx.continueAsNew(state);
}

Activity Retries

Wrap flaky activities with a retry policy instead of hand-coding retry loops. The orchestration replays cleanly because retries are recorded in history.

@FunctionName("ResilientOrchestrator")
public String resilientOrchestrator(
        @DurableOrchestrationTrigger(name = "ctx") TaskOrchestrationContext ctx) {
    Order order = ctx.getInput(Order.class);

    RetryPolicy policy = new RetryPolicy(3, Duration.ofSeconds(5));
    TaskOptions options = new TaskOptions(policy);

    return ctx.callActivity("ChargeCustomer", order, options, String.class).await();
}
Element Explanation
callSubOrchestrator Invokes another orchestrator as a child; compose and fan out like activities.
continueAsNew Restarts the orchestration with new input and a trimmed history for eternal loops.
RetryPolicy / TaskOptions Declarative retry policy passed to callActivity.

Versioning

Orchestrations replay from history, so changing an orchestrator's code while instances are in flight can break replay (non-determinism). Safe strategies:

  • Deploy side by side: give the changed orchestrator a new name and route new instances to it, letting existing instances drain on the old version.
  • Do not reorder or remove existing activity calls in a deployed orchestrator.
  • Terminate and restart in-flight instances if a breaking change is unavoidable.

Determinism still applies

Advanced patterns do not relax the determinism rule. Never call Instant.now(), generate random values, or do direct I/O inside an orchestrator — use activities and ctx.getCurrentInstant().

See Also

Sources