Skip to main content

Steps

Steps are the fundamental building blocks of sagas. Each step represents a single unit of work with optional compensation logic for rollback scenarios.

Defining Steps

Steps are created using the defineStep factory function, which provides full type inference for inputs, outputs, errors, and port dependencies:

import { defineStep } from "@hex-di/saga";
import { ok, err } from "neverthrow";

const ValidateOrderStep = defineStep({
name: "ValidateOrder",
port: OrderValidationPort,
execute: async (input, port) => {
const validation = await port.validate(input.orderId);
return validation.valid
? ok({ orderId: input.orderId, validated: true })
: err({ code: "INVALID_ORDER", message: validation.reason });
},
compensate: async context => {
await context.port.cancelValidation(context.input.orderId);
return ok(undefined);
},
retry: { maxAttempts: 3, delay: 1000, backoff: "exponential" },
timeout: 5000,
condition: (input, results) => input.requiresValidation !== false,
});

Step Configuration

name

The step name must be a literal string type that uniquely identifies the step within a saga:

const MyStep = defineStep({
name: "MyStep", // Literal type: "MyStep"
// ...
});

The name is used as a key in the accumulated results and for event tracking.

port

The port dependency defines which service the step requires for execution:

const PaymentStep = defineStep({
name: "ProcessPayment",
port: PaymentServicePort, // Port dependency
execute: async (input, port) => {
// 'port' is the resolved service instance
return await port.charge(input.amount);
},
});

execute

The execute function performs the step's primary work:

execute: async (input: TInput, port: TPort) => {
// Perform work using the port service
// Must return ResultAsync<TOutput, TError>
return ok(output) || err(error);
};

The function receives:

  • input: The step input (either saga input or mapped from previous steps)
  • port: The resolved port service instance

compensate (optional)

The compensate function defines rollback logic when the saga fails:

compensate: async (context: CompensationContext) => {
// Context provides:
// - context.input: Original step input
// - context.results: Accumulated results up to this step
// - context.originalError: The error that triggered compensation
// - context.port: The port service instance

// Perform cleanup/rollback
return ok(undefined) || err(compensationError);
};

retry (optional)

Configure automatic retry behavior for transient failures:

retry: {
maxAttempts: 3, // Maximum retry attempts
delay: 1000, // Initial delay in milliseconds
backoff: "exponential" // Backoff strategy: "linear" | "exponential" | "fixed"
}

Backoff strategies:

  • fixed: Same delay between all retries
  • linear: Delay increases linearly (delay * attemptNumber)
  • exponential: Delay doubles with each attempt

timeout (optional)

Set a maximum execution time for the step in milliseconds:

timeout: 5000; // 5 second timeout

If the step exceeds this timeout, it will be cancelled and marked as failed with a TimeoutError.

condition (optional)

Define a condition that determines whether the step should execute:

condition: (input, results) => {
// Return true to execute, false to skip
return input.orderTotal > 100;
};

The condition receives:

  • input: The step's input
  • results: Accumulated results from previous steps

Skipped steps emit a StepSkippedEvent and don't contribute to the results.

Step Context

During execution, steps operate within a context that provides:

interface StepContext {
executionId: string; // Unique execution identifier
stepName: string; // Current step name
stepIndex: number; // Position in execution order
attemptNumber: number; // Current retry attempt (1-based)
startedAt: Date; // Step start time
}

Compensation Context

When compensation runs, it receives additional context:

interface CompensationContext<TInput, TResults, TPort> {
input: TInput; // Original step input
results: TResults; // Accumulated results
originalError: unknown; // Error that triggered compensation
port: TPort; // Port service instance
executionId: string; // Execution identifier
stepName: string; // Step being compensated
}

Type Inference

The library provides utility types for extracting step information:

// Extract step name
type Name = InferStepName<typeof MyStep>; // "MyStep"

// Extract input type
type Input = InferStepInput<typeof MyStep>;

// Extract output type
type Output = InferStepOutput<typeof MyStep>;

// Extract error type
type Error = InferStepError<typeof MyStep>;

// Extract port type
type Port = InferStepPort<typeof MyStep>;

Best Practices

Keep Steps Focused

Each step should represent a single, cohesive unit of work:

// Good: Focused responsibility
const ReserveInventoryStep = defineStep({
name: "ReserveInventory",
port: InventoryPort,
execute: async (input, port) => {
return await port.reserve(input.items);
},
});

// Avoid: Multiple responsibilities
const ProcessOrderStep = defineStep({
name: "ProcessOrder",
port: MultiServicePort,
execute: async (input, port) => {
await port.validateOrder(input);
await port.reserveInventory(input);
await port.chargePayment(input);
// Too many responsibilities in one step
},
});

Always Define Compensation

If a step makes changes that should be reversed on failure, always define compensation:

const CreateResourceStep = defineStep({
name: "CreateResource",
port: ResourcePort,
execute: async (input, port) => {
const resource = await port.create(input);
return ok({ resourceId: resource.id });
},
compensate: async context => {
// Clean up the created resource
const result = context.results.CreateResource;
if (result?.resourceId) {
await context.port.delete(result.resourceId);
}
return ok(undefined);
},
});

Use Appropriate Timeouts

Set reasonable timeouts based on expected operation duration:

const QuickValidationStep = defineStep({
name: "QuickValidation",
port: ValidationPort,
execute: async (input, port) => {
return await port.validate(input);
},
timeout: 1000, // 1 second for quick validation
});

const SlowProcessingStep = defineStep({
name: "SlowProcessing",
port: ProcessingPort,
execute: async (input, port) => {
return await port.process(input);
},
timeout: 30000, // 30 seconds for heavy processing
});

Handle Errors Gracefully

Return structured errors with meaningful information:

const PaymentStep = defineStep({
name: "ProcessPayment",
port: PaymentPort,
execute: async (input, port) => {
const result = await port.charge(input);

if (result.isOk()) {
return ok({ transactionId: result.value });
}

// Return structured error
return err({
code: "PAYMENT_FAILED",
message: result.error.message,
details: {
amount: input.amount,
currency: input.currency,
reason: result.error.reason,
},
});
},
});

Next Steps