Back to KB
Difficulty
Intermediate
Read Time
8 min

Building reliable distributed workflows in TypeScript with HazelJS Sagas

By Codcompass Team··8 min read

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:

ApproachConsistency ModelRollback ComplexityCross-Vendor CompatibilityDebugging Overhead
Local ACID TransactionStrong (all-or-nothing)Low (database handles it)None (single system boundary)Minimal
Ad-Hoc Error HandlingNone (manual cleanup)High (fragile, divergent paths)HighVery High
Saga OrchestrationEventual (compensating actions)Medium (explicit by design)HighLow (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

  1. 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.
  2. 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.
  3. 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.
  4. 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

ScenarioRecommended ApproachWhyCost Impact
Short-lived workflow (< 5s), single team ownershipOrchestration with inline executionCentralized flow, easier debugging, lower infrastructure overheadLow
Multi-team workflow, independent deployment cyclesEvent choreographyDecouples services, prevents cross-team deployment bottlenecksMedium (event bus, monitoring)
Human-in-the-loop or async approval stepsState machine + message queuePrevents request timeouts, supports pause/resume semanticsMedium (queue infrastructure, state persistence)
High-volume checkout or provisioningSaga with idempotency + persistent stateGuarantees consistency under retries, survives process restartsMedium-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

  1. Install the package: Run npm install @hazeljs/saga in your TypeScript project.
  2. Define your context: Create an interface that holds all state required across steps and compensators.
  3. Decorate your workflow: Use @Saga on the class and @SagaStep on forward methods. Bind compensators via the compensate property.
  4. Trigger execution: Call SagaOrchestrator.getInstance().start('workflow-name', context) from your controller or service layer.
  5. Verify compensation: Simulate a step failure and confirm that compensators execute in reverse order and the final state reflects the rollback.