Sagas
Sagas orchestrate multiple steps into complex workflows with defined execution flows, error handling, and compensation strategies.
The Saga Builder
The defineSaga function provides a progressive builder pattern that guides you through creating type-safe sagas:
import { defineSaga } from "@hex-di/saga";
const OrderProcessingSaga = defineSaga("OrderProcessing")
.input<OrderInput>()
.step(ValidateOrderStep)
.step(ReserveInventoryStep)
.parallel([NotifyWarehouseStep, UpdateAnalyticsStep])
.branch((input, results) => results.ValidateOrder.orderType, {
express: [ExpressShippingStep],
standard: [StandardShippingStep],
pickup: [StorePickupStep],
})
.saga(SubSaga, (input, results) => ({
orderId: results.ValidateOrder.orderId,
}))
.output(results => ({
orderId: results.ValidateOrder.orderId,
status: "completed" as const,
}))
.options({
compensationStrategy: "sequential",
persistent: true,
})
.validate(input => {
if (!input.orderId) {
return err({ code: "MISSING_ORDER_ID" });
}
return ok(input);
})
.version("1.0.0")
.build();
Builder Stages
The saga builder follows a three-stage progression:
Stage 1: Name Definition
const saga = defineSaga("MySagaName");
The saga name must be a literal string type.
Stage 2: Input Definition
.input<MyInputType>()
Define the input type the saga will receive. This type flows through to all steps and mappers.
Stage 3: Step Composition
Add steps using various composition methods:
Sequential Steps
Execute steps one after another:
.step(FirstStep)
.step(SecondStep)
.step(ThirdStep)
Each step receives the saga input and can access results from previous steps.
Parallel Steps
Execute multiple steps concurrently:
.parallel([
StepA,
StepB,
StepC
])
All parallel steps start simultaneously and the saga waits for all to complete.
Branching
Conditionally execute different step sequences:
.branch(
(input, results) => {
// Return a branch key
return input.customerType;
},
{
premium: [PremiumFlowStep, BonusStep],
standard: [StandardFlowStep],
guest: [GuestFlowStep, VerificationStep]
}
)
The selector function determines which branch to execute based on input and accumulated results.
Sub-Sagas
Compose sagas within sagas:
.saga(
PaymentProcessingSaga,
(input, results) => ({
// Map input for the sub-saga
amount: input.orderTotal,
customerId: results.ValidateOrder.customerId
})
)
Sub-sagas are fully independent executions with their own compensation.
Stage 4: Output Mapping
Transform accumulated results into the saga output:
.output((results) => ({
orderId: results.ValidateOrder.orderId,
paymentId: results.ProcessPayment.transactionId,
shippingId: results.CreateShipment.trackingNumber
}))
Saga Options
Configure saga behavior with options:
.options({
compensationStrategy: "sequential",
persistent: true,
maxConcurrency: 5,
timeout: 60000,
hooks: {
beforeStep: async (context) => { /* ... */ },
afterStep: async (context) => { /* ... */ },
beforeCompensation: async (context) => { /* ... */ },
afterCompensation: async (context) => { /* ... */ }
},
metadata: {
team: "orders",
sla: "critical"
},
checkpointPolicy: "swallow"
})
compensationStrategy
Determines how compensation executes on failure:
- "sequential" (default): Reverse order, stops on first failure
- "parallel": All compensations run concurrently
- "best-effort": Reverse order, continues despite failures
persistent
Enable persistence for crash recovery:
persistent: true; // Enable checkpointing
maxConcurrency
Limit concurrent step execution in parallel blocks:
maxConcurrency: 5; // Maximum 5 concurrent steps
timeout
Global saga execution timeout in milliseconds:
timeout: 60000; // 60 second timeout
hooks
Lifecycle hooks for observability and side effects:
hooks: {
beforeStep: async (context: StepHookContext) => {
console.log(`Starting step: ${context.stepName}`);
},
afterStep: async (context: StepHookResultContext) => {
console.log(`Step completed: ${context.stepName}`);
},
beforeCompensation: async (context: CompensationHookContext) => {
console.log("Starting compensation");
},
afterCompensation: async (context: CompensationResultHookContext) => {
console.log("Compensation completed");
}
}
metadata
Attach arbitrary metadata to the saga:
metadata: {
team: "orders",
sla: "critical",
region: "us-east"
}
checkpointPolicy
Control checkpoint failure handling:
- "swallow" (default): Log and continue on checkpoint failure
- "abort": Stop execution on checkpoint failure
- "warn": Log warning and continue
Input Validation
Add runtime validation for saga inputs:
.validate((input) => {
if (!input.orderId || !input.customerId) {
return err({
code: "INVALID_INPUT",
message: "Missing required fields"
});
}
if (input.amount <= 0) {
return err({
code: "INVALID_AMOUNT",
message: "Amount must be positive"
});
}
return ok(input);
})
Versioning
Track saga versions for compatibility:
.version("1.0.0")
Versions are checked when resuming persisted executions.
Accumulated Results
As steps execute, their outputs accumulate in a typed results object:
interface AccumulatedResults {
[StepName: string]: StepOutput;
}
For example:
const saga = defineSaga("Example")
.input<{ orderId: string }>()
.step(ValidateStep) // Output: { valid: boolean }
.step(ProcessStep) // Output: { processId: string }
.output(results => {
// results type:
// {
// Validate: { valid: boolean }
// Process: { processId: string }
// }
return {
isValid: results.Validate.valid,
id: results.Process.processId,
};
})
.build();
Type Safety
The saga builder provides complete type inference:
// Infer saga types
type SagaName = InferSagaName<typeof MySaga>; // Literal name
type SagaInput = InferSagaInput<typeof MySaga>; // Input type
type SagaOutput = InferSagaOutput<typeof MySaga>; // Output type
type SagaSteps = InferSagaSteps<typeof MySaga>; // Step union
type SagaErrors = InferSagaErrors<typeof MySaga>; // Error union
// Get step output by name
type ValidateOutput = InferStepOutputByName<typeof MySaga, "Validate">;
Advanced Patterns
Conditional Execution
Skip steps based on conditions:
const ConditionalSaga = defineSaga("Conditional")
.input<{ skipValidation?: boolean }>()
.step(ValidateStep) // Has condition: (input) => !input.skipValidation
.step(ProcessStep)
.build();
Dynamic Branching
Branch based on accumulated results:
.branch(
(input, results) => {
// Branch based on previous step results
if (results.RiskAssessment.score > 80) {
return "high-risk";
} else if (results.RiskAssessment.score > 40) {
return "medium-risk";
} else {
return "low-risk";
}
},
{
"high-risk": [ManualReviewStep, ApprovalStep],
"medium-risk": [AutomatedReviewStep],
"low-risk": [FastTrackStep]
}
)
Nested Parallel Execution
Combine parallel and sequential patterns:
.step(InitializeStep)
.parallel([
ValidationStep,
EnrichmentStep
])
.step(ProcessStep)
.parallel([
NotificationStep,
AuditStep,
MetricsStep
])
.step(FinalizeStep)
Error Aggregation
Collect errors from parallel steps:
.parallel([StepA, StepB, StepC])
.output((results, errors) => {
// Handle both successes and failures
const successful = Object.keys(results).length;
const failed = Object.keys(errors).length;
return {
successful,
failed,
results
};
})
Best Practices
Name Sagas Clearly
Use descriptive names that indicate the business process:
// Good
defineSaga("OrderFulfillment");
defineSaga("PaymentProcessing");
defineSaga("UserOnboarding");
// Avoid
defineSaga("Saga1");
defineSaga("Process");
defineSaga("Handler");
Keep Sagas Focused
Each saga should represent a cohesive business transaction:
// Good: Focused on order processing
const OrderSaga = defineSaga("OrderProcessing")
.input<OrderInput>()
.step(ValidateOrderStep)
.step(ReserveInventoryStep)
.step(ProcessPaymentStep)
.step(CreateShipmentStep)
.build();
// Avoid: Too many unrelated concerns
const EverythingSaga = defineSaga("Everything")
.input<AnyInput>()
.step(OrderStep)
.step(UserProfileStep)
.step(EmailCampaignStep)
.step(ReportGenerationStep)
.build();
Use Sub-Sagas for Reusability
Extract common workflows into reusable sub-sagas:
const PaymentSaga = defineSaga("Payment")
.input<PaymentInput>()
.step(ValidatePaymentStep)
.step(ProcessPaymentStep)
.step(RecordTransactionStep)
.build();
// Reuse in multiple parent sagas
const OrderSaga = defineSaga("Order")
.input<OrderInput>()
.step(ValidateOrderStep)
.saga(PaymentSaga, mapToPaymentInput)
.build();
const SubscriptionSaga = defineSaga("Subscription")
.input<SubscriptionInput>()
.step(ValidateSubscriptionStep)
.saga(PaymentSaga, mapToPaymentInput)
.build();
Next Steps
- Learn about Compensation - Rollback strategies for failed sagas
- Understand Execution - How sagas run and emit events
- Build Your First Saga - Step-by-step guide