Skip to content

Code organization

Organize your code so that your workflows are composable and testable.

Separate orchestration from business logic

Business logic is the work a step performs. Orchestration is when and in what order steps run.

Put your logic in its own testable functions that the durable operation calls, rather than inline in the operation. Keep DurableContext out of your domain logic.

// Business logic: no DurableContext, pure work.
async function validateOrder(order: Order): Promise<ValidationResult> {
  return orderValidator.validate(order);
}

async function chargePayment(order: Order): Promise<Receipt> {
  return paymentService.charge(order.total, order.cardToken);
}

async function scheduleShipment(order: Order): Promise<ShipmentId> {
  return shipmentService.schedule(order.id, order.address);
}

// Orchestration: the handler reads as a sequence of intent.
export const handler = withDurableExecution(
  async (order: Order, context: DurableContext) => {
    await context.step("validate", () => validateOrder(order));
    const receipt = await context.step("charge", () => chargePayment(order));
    const shipmentId = await context.step("schedule", () => scheduleShipment(order));
    return { receipt, shipmentId };
  },
);
def validate_order(order: dict) -> dict:
    return order_validator.validate(order)


def charge_payment(order: dict) -> dict:
    return payment_service.charge(order["total"], order["card_token"])


def schedule_shipment(order: dict) -> str:
    return shipment_service.schedule(order["id"], order["address"])


@durable_step
def validate_step(ctx: StepContext, order: dict) -> dict:
    return validate_order(order)


@durable_step
def charge_step(ctx: StepContext, order: dict) -> dict:
    return charge_payment(order)


@durable_step
def schedule_step(ctx: StepContext, order: dict) -> str:
    return schedule_shipment(order)


@durable_execution
def handler(order: dict, context: DurableContext) -> dict:
    context.step(validate_step(order))
    receipt = context.step(charge_step(order))
    shipment_id = context.step(schedule_step(order))
    return {"receipt": receipt, "shipmentId": shipment_id}
// Business logic lives on service classes, not the handler.
public class OrderWorkflow implements DurableHandler<Order, OrderResult> {
    private final OrderValidator validator;
    private final PaymentService payments;
    private final ShipmentService shipments;

    @Override
    public OrderResult handle(Order order, DurableContext context) {
        context.step("validate", ValidationResult.class,
            ctx -> validator.validate(order));

        Receipt receipt = context.step("charge", Receipt.class,
            ctx -> payments.charge(order.total(), order.cardToken()));

        String shipmentId = context.step("schedule", String.class,
            ctx -> shipments.schedule(order.id(), order.address()));

        return new OrderResult(receipt, shipmentId);
    }
}

Tip

Simplify your unit testing by not referencing DurableContext in your domain logic functions. Cover orchestration separately with the durable function testing framework.

Group operations with child contexts

A child context is a named scope that groups operations in the execution history. Inside a child context you can call any durable operation, including nested child contexts.

Use a child context when a block of work is one logical unit that is composed of several operations.

await context.runInChildContext("process-order", async (child) => {
  await child.step("validate", () => validate(order));
  const receipt = await child.step("charge", () => charge(order));
  await child.step("schedule", () => schedule(order, receipt));
  return "ok";
});
from aws_durable_execution_sdk_python import durable_with_child_context


@durable_with_child_context
def process_order(child: DurableContext, order: dict) -> str:
    child.step(validate_step(order))
    child.step(charge_step(order))
    child.step(schedule_step(order))
    return "ok"


context.run_in_child_context(process_order(order), name="process-order")
context.runInChildContext("process-order", String.class, child -> {
    child.step("validate", ValidationResult.class,
        ctx -> validator.validate(order));
    Receipt receipt = child.step("charge", Receipt.class,
        ctx -> payments.charge(order.total(), order.cardToken()));
    child.step("schedule", String.class,
        ctx -> shipments.schedule(order.id(), order.address()));
    return "ok";
});

When several steps share the same retry strategy, timeout, or serdes, define the configuration once and reuse it. Use the name of a configuration object to make its intent clear.

import { retryPresets, StepSemantics } from "@aws/durable-execution-sdk-js";

const paymentStepConfig = {
  semantics: StepSemantics.AtMostOncePerRetry,
  retryStrategy: () => ({ shouldRetry: false }),
};

const idempotentStepConfig = {
  retryStrategy: retryPresets.default,
};

await context.step("charge", () => chargePayment(order), paymentStepConfig);
await context.step("refund", () => refundPayment(order), paymentStepConfig);
await context.step("fetch-user", () => userStore.get(id), idempotentStepConfig);
from aws_durable_execution_sdk_python.config import StepConfig, StepSemantics
from aws_durable_execution_sdk_python.retries import RetryPresets

PAYMENT_CONFIG = StepConfig(
    step_semantics=StepSemantics.AT_MOST_ONCE_PER_RETRY,
    retry_strategy=RetryPresets.none(),
)
IDEMPOTENT_CONFIG = StepConfig(retry_strategy=RetryPresets.default())

context.step(charge_step(order), config=PAYMENT_CONFIG)
context.step(refund_step(order), config=PAYMENT_CONFIG)
context.step(fetch_user(id), config=IDEMPOTENT_CONFIG)
private static final StepConfig PAYMENT_CONFIG = StepConfig.builder()
    .semantics(StepSemantics.AT_MOST_ONCE_PER_RETRY)
    .retryStrategy(RetryStrategies.Presets.NO_RETRY)
    .build();

private static final StepConfig IDEMPOTENT_CONFIG = StepConfig.builder()
    .retryStrategy(RetryStrategies.Presets.DEFAULT)
    .build();

context.step("charge", Receipt.class,
    ctx -> payments.charge(order), PAYMENT_CONFIG);
context.step("refund", Receipt.class,
    ctx -> payments.refund(order), PAYMENT_CONFIG);
context.step("fetch-user", User.class,
    ctx -> users.get(id), IDEMPOTENT_CONFIG);

Run independent work concurrently

Use parallel for a fixed number of named branches and map to iterate over a variable-length list. Both are durable so each branch checkpoints independently and survives Lambda timeouts or sandbox crashes, unlike language-specific constructs such as Promise.all, asyncio.gather, or CompletableFuture.

// A small number of fixed branches.
const { fx, weather, quote } = await context.parallel("enrich", [
  async (ctx) => ctx.step("fx", () => fxRates.latest()),
  async (ctx) => ctx.step("weather", () => weatherApi.get()),
  async (ctx) => ctx.step("quote", () => quoteApi.get()),
]);

// A variable number of items.
const results = await context.map(items, async (ctx, item) =>
  ctx.step("process", () => process(item)),
  { maxConcurrency: 10 },
);
# A small number of fixed branches.
results = context.parallel(
    [
        lambda ctx: ctx.step(fx_rates_latest()),
        lambda ctx: ctx.step(weather_api_get()),
        lambda ctx: ctx.step(quote_api_get()),
    ],
    name="enrich",
)

# A variable number of items.
processed = context.map(items, process, name="process-items")
// A small number of fixed branches.
var enrich = context.parallel("enrich")
    .step("fx", FxRates.class, ctx -> fxRates.latest())
    .step("weather", Weather.class, ctx -> weatherApi.get())
    .step("quote", Quote.class, ctx -> quoteApi.get())
    .run();

// A variable number of items.
BatchResult<Result> processed = context.map(
    "process-items",
    items,
    Result.class,
    (ctx, item) -> process(item));

See the parallel and map references for completion policies, concurrency limits, and per-item configuration.

See also