Building reliable distributed workflows in TypeScript with HazelJS Sagas
Current Situation Analysis
Modern backend architectures rarely confine a single business operation to one database. A checkout flow touches a payment gateway, an inventory system, and a notification provider. A SaaS onboarding sequence provisions a tenant, allocates cloud resources, and configures access controls. These systems operate independently, expose REST or gRPC endpoints, and maintain their own transaction logs. They do not share a global lock.
Developers frequently approach these distributed pipelines with a monolithic mindset. They wrap the sequence in nested try/catch blocks, sprinkle manual cleanup logic, and hope that error paths remain synchronized. This approach fractures under production load. When a payment processor times out after inventory is reserved, the system either leaks resources (orphaned holds) or double-charges customers on retry. The core misunderstanding is treating distributed workflows as if they require strict ACID guarantees. In reality, cross-boundary operations demand eventual consistency paired with explicit failure recovery.
Two-phase commit (2PC) theoretically solves this, but it requires every participant to support a distributed transaction coordinator. Third-party vendors, legacy PMS systems, and cloud APIs rarely expose this capability. The industry standard for bridging this gap is the Saga pattern. Instead of a single atomic commit, a saga decomposes the workflow into discrete local steps. Each step that modifies external state is paired with a compensating action. If the pipeline fails mid-execution, the orchestrator triggers compensators in reverse order, restoring business consistency without requiring vendor-level transaction support.
WOW Moment: Key Findings
The trade-off between traditional transactional models and saga orchestration becomes stark when measured against real-world operational metrics. The following comparison illustrates why sagas dominate cross-service workflows:
| Approach | Consistency Model | Rollback Complexity | Cross-Vendor Compatibility | Debugging Overhead |
|---|---|---|---|---|
| Local ACID Transaction | Strong (all-or-nothing) | Low (database handles it) | None (single system boundary) | Minimal |
| Ad-Hoc Error Handling | None (manual cleanup) | High (fragile, divergent paths) | High | Very High |
| Saga Orchestration | Eventual (compensating actions) | Medium (explicit by design) | High | Low (centralized flow) |
This finding matters because it shifts the engineering focus from preventing failure to designing recovery. Sagas acknowledge that network partitions, API rate limits, and third-party outages are inevitable. By encoding compensators as first-class citizens alongside forward steps, teams gain predictable rollback behavior, simplified incident response, and a single source of truth for workflow state. This enables reliable checkout, provisioning, and booking flows without demanding architectural concessions from external vendors.
Core Solution
Implementing a saga requires three components: a state container, a decorated workflow class, and an orchestrator that manages execution and compensation. We will build a SaaS tenant provisioning pipeline using @hazeljs/saga. The flow creates a tenant record, provisions a dedicated database instance, and sends a welcome notification. If provisioning fails, the tenant record is archived and the database allocation is revoked.
Step 1: Define the Shared State Interface
The orchestrator passes a single context object through every step and compensator. This avoids boilerplate context-passing and mirrors how teams thread draft DTOs through pipelines.
export interface TenantProvisioningContext {
tenantId: string;
dbInstanceId: string | null;
notificationSent: boolean;
provisioningStatus: 'PENDING' | 'COMPLETED' | 'COMPENSATED' | 'FAILED';
}
Step 2: Implement the Saga Workflow
Decorators register the workflow and bind forward steps to their compensators. The orchestrator reads these metadata annotations at runtime.
import { Saga, SagaStep, SagaOrchestrator } from '@hazeljs/saga';
import { TenantRegistry } from '../services/tenant-registry';
import { CloudAllocator } from '../services/cloud-allocator';
import { MessagingGateway } from '../services/messaging-gateway';
import { TenantProvisioningContext } from './tenant-provisioning.context';
@Saga({ name: 'tenant-provisioning' })
export class TenantProvisioningSaga {
private readonly tenantRegistry = new TenantRegistry();
private readonly cloudAllocator = new CloudAllocator();
private readonly messagingGateway = new MessagingGateway();
@SagaStep({ order: 1, compensate: 'archiveTenant' })
async createTenantRecord(ctx: TenantProvisioningContext): Promise<void> {
const record = await this.tenantRegistry.register({
name: ctx.tenantId,
status: 'INITIALIZING',
});
ctx.tenantId = record.id;
}
@SagaStep({ order: 2, compensate: 'deallocateDatabase' })
async provisionDatabase(ctx: TenantProvisioningContext): Promise<void> {
const instance = await this.cloudAllocator.create({
tenantRef: ctx.tenantId,
tier: 'STANDARD',
});
ctx.dbInstanceId = instance.identifier;
}
@SagaStep({ order: 3 })
async sendWelcomeNotification(ctx: TenantProvisioningContext): Promise<void> {
await this.messagingGateway.deliver({
recipient: ctx.tenantId,
template: 'ONBOARDING_SUCCESS',
payload: { dbRef: ctx.dbInstanceId },
});
ctx.notificationSent =
true; }
// Compensators execute only during rollback. They are not decorated. async archiveTenant(ctx: TenantProvisioningContext): Promise<void> { await this.tenantRegistry.updateStatus(ctx.tenantId, 'ARCHIVED'); }
async deallocateDatabase(ctx: TenantProvisioningContext): Promise<void> { if (ctx.dbInstanceId) { await this.cloudAllocator.terminate(ctx.dbInstanceId); ctx.dbInstanceId = null; } } }
### Step 3: Wire the Orchestrator
The HTTP layer triggers the workflow. The orchestrator resolves the registered saga by name, executes forward steps sequentially, and triggers compensators in reverse order if any step throws.
```typescript
import { SagaOrchestrator } from '@hazeljs/saga';
import { TenantProvisioningContext } from './tenant-provisioning.context';
async function executeProvisioning(tenantName: string) {
const initialContext: TenantProvisioningContext = {
tenantId: tenantName,
dbInstanceId: null,
notificationSent: false,
provisioningStatus: 'PENDING',
};
const result = await SagaOrchestrator.getInstance().start(
'tenant-provisioning',
initialContext
);
return {
status: result.status,
finalState: result.data,
stepLog: result.steps,
errorDetail: result.failureReason,
};
}
Architecture Decisions & Rationale
- Decorator-Based Registration: Class and method decorators (
@Saga,@SagaStep) decouple workflow definition from execution logic. The orchestrator scans the module graph at startup, building a registry without manual configuration files. This reduces boilerplate and keeps flow metadata adjacent to implementation. - Shared Mutable Context: Passing a single object reference across steps eliminates the need for a separate context bag or dependency injection container per step. It mirrors real-world DTO threading and simplifies state inspection during debugging.
- Inline Execution Model: The orchestrator runs steps synchronously within the request lifecycle. This is optimal for short-lived, low-latency workflows. Long-running or human-in-the-loop steps should be offloaded to a state machine or message queue, as detailed in the Pitfall Guide.
- Compensator Placement: Compensators are standard async methods on the same class. They are not decorated because they only execute during rollback. This keeps the forward flow declarative while preserving full TypeScript type safety for rollback logic.
Pitfall Guide
1. Assuming Sagas Provide Strong Consistency
Explanation: Sagas guarantee eventual consistency, not atomicity. If two requests trigger the same workflow concurrently, race conditions can corrupt shared state. Fix: Implement idempotency keys derived from the saga ID and step name. Use optimistic concurrency control or distributed locks when modifying shared resources.
2. Missing Compensators for Critical Steps
Explanation: Developers often forget to define rollback paths for steps that allocate external resources (e.g., cloud instances, third-party licenses). When a later step fails, those resources leak. Fix: Audit every forward step. If it modifies external state, it must have a compensator. Use static analysis or CI checks to enforce compensator coverage.
3. Blocking the Request Thread for Long Operations
Explanation: The inline orchestrator holds the HTTP connection open until all steps complete. Steps involving human approval, batch processing, or slow third-party APIs will cause timeouts. Fix: Split long-running workflows into a state machine. Use the saga for synchronous setup, then emit an event to a queue. Resume execution via webhook or polling.
4. Swallowing Compensation Failures
Explanation: If a compensator throws (e.g., the payment refund API is down), the orchestrator cannot automatically retry. The system remains in an inconsistent state. Fix: Implement dead-letter queues for failed compensations. Emit structured alerts and maintain manual intervention playbooks. Never let compensation errors fail silently.
5. Overcomplicating with Event Choreography Too Early
Explanation: Choreography distributes flow logic across multiple services via events. While loosely coupled, it makes debugging and incident response significantly harder. Fix: Start with orchestration. It provides a single source of truth for the workflow. Migrate to choreography only when team boundaries, deployment velocity, or service ownership explicitly demand it.
6. Mutable State Race Conditions
Explanation: The shared context object is mutated in place. If the orchestrator runs multiple sagas concurrently in the same process, context bleeding can occur. Fix: Clone the initial context before passing it to the orchestrator. Alternatively, use immutable state snapshots where each step returns a new context object.
7. Lack of Observability
Explanation: Without explicit logging at step transitions, tracing a failed saga requires reconstructing state from scattered service logs. Fix: Emit structured logs at each step entry, exit, and compensation trigger. Include the saga ID, step order, and execution duration. Integrate with distributed tracing systems.
Production Bundle
Action Checklist
- Define idempotency strategy: Generate deterministic keys from saga ID + step name for all external API calls.
- Implement compensator coverage: Verify every state-mutating step has a corresponding rollback method.
- Add persistence layer: Store saga execution history, step outcomes, and final state in a durable store for audit and recovery.
- Configure timeout boundaries: Set explicit timeouts for each step. Fail fast rather than blocking indefinitely.
- Instrument observability: Emit metrics for step latency, compensation frequency, and saga completion rates.
- Design compensation failure handling: Route failed compensators to a dead-letter queue with alerting and manual retry workflows.
- Test failure paths: Write integration tests that simulate third-party timeouts, partial successes, and compensator failures.
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Short-lived workflow (< 5s), single team ownership | Orchestration with inline execution | Centralized flow, easier debugging, lower infrastructure overhead | Low |
| Multi-team workflow, independent deployment cycles | Event choreography | Decouples services, prevents cross-team deployment bottlenecks | Medium (event bus, monitoring) |
| Human-in-the-loop or async approval steps | State machine + message queue | Prevents request timeouts, supports pause/resume semantics | Medium (queue infrastructure, state persistence) |
| High-volume checkout or provisioning | Saga with idempotency + persistent state | Guarantees consistency under retries, survives process restarts | Medium-High (DB writes, retry logic) |
Configuration Template
// app.module.ts
import { SagaOrchestrator } from '@hazeljs/saga';
import { TenantProvisioningSaga } from './sagas/tenant-provisioning.saga';
// Side-effect import triggers decorator registration
import './sagas/tenant-provisioning.saga';
export class ApplicationModule {
static async bootstrap() {
const orchestrator = SagaOrchestrator.getInstance();
// Optional: Configure global timeout and retry policy
orchestrator.configure({
defaultStepTimeoutMs: 5000,
maxCompensationRetries: 2,
enableStructuredLogging: true,
});
// Verify registry before accepting traffic
const registered = orchestrator.getRegisteredSagas();
if (!registered.includes('tenant-provisioning')) {
throw new Error('Saga registry initialization failed');
}
}
}
Quick Start Guide
- Install the package: Run
npm install @hazeljs/sagain your TypeScript project. - Define your context: Create an interface that holds all state required across steps and compensators.
- Decorate your workflow: Use
@Sagaon the class and@SagaStepon forward methods. Bind compensators via thecompensateproperty. - Trigger execution: Call
SagaOrchestrator.getInstance().start('workflow-name', context)from your controller or service layer. - Verify compensation: Simulate a step failure and confirm that compensators execute in reverse order and the final state reflects the rollback.
