Sagas

Durable workflows that survive restarts and span long-running processes

Sagas are durable workflows for processes that span multiple steps, external calls, and potentially long time periods. Unlike regular request handlers, sagas persist their progress and can resume after crashes, restarts, or when waiting for external events.

ℹ️

Info

Use sagas when your workflow needs to wait for external events (email verification, payment webhooks), survive server restarts, or coordinate multiple steps with compensating actions on failure.

When to Use Sagas

Sagas are ideal for:

  • Multi-step authentication - Password reset with email verification, 2FA setup
  • Order processing - Payment, inventory, shipping coordination
  • Onboarding flows - User registration spanning multiple days/steps
  • Long-running jobs - Data migrations, report generation with progress tracking
  • External integrations - Waiting for webhooks, third-party callbacks

Don't use sagas for simple CRUD operations or stateless request/response handlers.

Basic Saga with createSaga

Create a saga with createSaga. Sagas use async/await syntax with dependency injection and typed signals. Here's a password reset flow that waits for email verification:

password-reset.tsTypeScript
import { createSaga } from '@justscale/saga';
import { z } from 'zod';

export const PasswordResetSaga = createSaga({
  path: 'auth/password-reset',

  // Inject dependencies
  inject: {
    users: UserService,
    email: EmailService,
  },

  // Declare signals with Zod schemas for type safety
  signals: {
    'email-verified': z.object({
      token: string(),
      verified: z.boolean(),
    }),
    'password-submitted': z.object({
      newPassword: z.string(),
    }),
  },

  // Body schema for input validation
  body: z.object({
    email: z.string().email(),
  }),

  // Handler receives injected deps, returns async function
  handler: ({ users, email }) =>
    async (ctx, { step, waitFor, compensate }) => {
      // Generate and send reset token
      const token = crypto.randomUUID();

      await step('send-reset-email', async () => {
        await email.send({
          to: ctx.body.email,
          subject: 'Reset Password',
          body: `Reset link: https://app.example.com/reset?token=${token}`,
        });
      });

      // Wait for user to click link (typed signal!)
      const verification = await waitFor('email-verified');

      if (!verification.verified) {
        throw new Error('Email verification failed');
      }

      // Wait for new password submission
      const { newPassword } = await waitFor('password-submitted');

      await step('update-password', async () => {
        await users.updatePassword(ctx.body.email, newPassword);
      });

      return { success: true };
    },
});

Core Concepts

Dependency Injection

Sagas use the same inject: pattern as services. Dependencies are resolved from the DI container and passed to the handler factory:

di-example.tsTypeScript
const OrderSaga = createSaga({
  path: 'orders/process',

  inject: {
    payments: PaymentService,
    inventory: InventoryService,
    shipping: ShippingService,
  },

  // Handler factory receives resolved dependencies
  handler: ({ payments, inventory, shipping }) =>
    async (ctx, ops) => {
      // Use injected services
      const charge = await ops.step('charge', () =>
        payments.charge(ctx.body.amount)
      );

      // Services are fully typed
      await ops.step('ship', () =>
        shipping.createShipment(ctx.params.orderId)
      );

      return { charged: charge.id };
    },
});

Typed Signals

Declare signals with Zod schemas for compile-time and runtime type safety. The waitFor function is typed based on declared signals:

typed-signals.tsTypeScript
const PaymentSaga = createSaga({
  path: 'payments/process',

  signals: {
    // Declare all signals the saga can receive
    'payment-confirmed': z.object({
      transactionId: z.string(),
      amount: z.number(),
    }),
    'payment-failed': z.object({
      reason: z.string(),
    }),
    'refund-requested': z.object({
      reason: z.string().optional(),
    }),
  },

  handler: () => async (ctx, { waitFor }) => {
    // waitFor is typed - only accepts declared signal names
    const payment = await waitFor('payment-confirmed');
    //    ^? { transactionId: string, amount: number }

    // TypeScript error if signal not declared:
    // await waitFor('undeclared-signal'); // Error!

    return { transactionId: payment.transactionId };
  },
});

Steps

Steps are units of work that get cached. If a saga resumes, completed steps return their cached result without re-executing:

steps.tsTypeScript
handler: () => async (ctx, { step }) => {
  // This step only runs once, even if saga restarts
  const userId = await step('create-user', async () => {
    // Side effect - only happens once
    return await userService.create({ email: 'user@example.com' });
  });

  // On resume, step returns cached userId without calling userService again
  console.log(userId); // Same value on replay
}

Compensations

Register compensating actions to undo steps on failure:

compensations.tsTypeScript
handler: ({ payments, inventory }) =>
  async (ctx, { step, compensate }) => {
    // Reserve inventory
    const reservation = await step('reserve', async () => {
      return inventory.reserve(ctx.body.items);
    });

    // Register compensation - runs if saga fails later
    compensate('reserve', async () => {
      await inventory.release(reservation.id);
    });

    // Charge payment
    const charge = await step('charge', async () => {
      return payments.charge(ctx.body.amount);
    });

    compensate('charge', async () => {
      await payments.refund(charge.id);
    });

    // If shipping fails, compensations run in reverse:
    // 1. Refund payment
    // 2. Release inventory
    await step('ship', async () => {
      return shipping.create(reservation.id);
    });

    return { success: true };
  }

Sleeping

Suspend a saga for a duration:

sleep.tsTypeScript
import { seconds, minutes, hours, days } from '@justscale/saga';

handler: () => async (ctx, { step, sleep }) => {
  await step('send-welcome', () => sendWelcomeEmail(ctx.body.email));

  // Wait 24 hours before sending reminder
  await sleep(hours(24));

  await step('send-reminder', () => sendReminderEmail(ctx.body.email));

  // Can also use:
  await sleep(minutes(30));
  await sleep(seconds(10));
  await sleep(days(7));

  return { done: true };
}

Feature Integration

Sagas integrate with the feature builder like services:

order-feature.tsTypeScript
import { createFeatureBuilder, bindService } from '@justscale/core';
import { SagaStorage, InMemorySagaStorage } from '@justscale/saga';

// Define the saga
const ProcessOrderSaga = createSaga({
  path: 'orders/process',
  inject: {
    payments: PaymentService,
    inventory: InventoryService,
  },
  signals: {
    'payment-confirmed': z.object({ txId: z.string() }),
  },
  handler: ({ payments, inventory }) =>
    async (ctx, ops) => {
      // ... saga logic
    },
});

// Add to feature
export const OrderFeature = createFeatureBuilder()
  .name('orders')
  .provides((b) => b
    // Services
    .add(PaymentService)
    .add(InventoryService)

    // Storage binding (use InMemory for dev, Postgres for prod)
    .add(bindService(SagaStorage, InMemorySagaStorage))

    // Saga
    .add(ProcessOrderSaga)

    // HTTP endpoints
    .add(OrderController)
  );

HTTP Integration with Plugins

Use sagaStart and sagaSignal plugins to expose sagas via HTTP:

order-controller.tsTypeScript
import { createController, Post } from '@justscale/core';
import { body } from '@justscale/http';
import { sagaStart, sagaSignal } from '@justscale/saga';
import { ProcessOrderSaga } from './sagas';

export const OrderController = createController('/orders', {
  routes: (_, { Post }) => ({
    // Start a new saga
    create: Post('/')
      .apply(body(z.object({
        items: z.array(z.object({
          productId: z.string(),
          quantity: z.number(),
        })),
      })))
      .apply(sagaStart(ProcessOrderSaga))
      .handle(({ sagaResult }) => ({
        workflowId: sagaResult.workflowId,
        status: sagaResult.status,
      })),

    // Send signal to existing saga
    confirmPayment: Post('/:workflowId/confirm-payment')
      .apply(body(z.object({ txId: z.string() })))
      .apply(sagaSignal(ProcessOrderSaga))
      .handle(({ signalResult }) => signalResult),
  }),
});

Complete Example: Order Processing

Here's a comprehensive example showing an order processing workflow with dependency injection, typed signals, and compensations:

order-saga.tsTypeScript
import { createSaga, minutes } from '@justscale/saga';
import { z } from 'zod';

export const ProcessOrderSaga = createSaga({
  path: 'orders/:orderId',

  inject: {
    payments: PaymentService,
    inventory: InventoryService,
    shipping: ShippingService,
    notifications: NotificationService,
  },

  signals: {
    'payment-confirmed': z.object({
      transactionId: z.string(),
      amount: z.number(),
    }),
    'shipping-update': z.object({
      status: z.enum(['shipped', 'delivered']),
      trackingNumber: z.string().optional(),
    }),
  },

  body: z.object({
    customerId: z.string(),
    items: z.array(z.object({
      productId: z.string(),
      quantity: z.number(),
      price: z.number(),
    })),
  }),

  handler: ({ payments, inventory, shipping, notifications }) =>
    async (ctx, { step, waitFor, compensate, sleep }) => {
      const { orderId } = ctx.params;
      const { customerId, items } = ctx.body;

      // Step 1: Reserve inventory
      const reservation = await step('reserve-inventory', async () => {
        return inventory.reserve(orderId, items);
      });

      compensate('reserve-inventory', async () => {
        await inventory.release(reservation.id);
      });

      // Step 2: Create payment intent
      const total = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
      const paymentIntent = await step('create-payment-intent', async () => {
        return payments.createIntent({ amount: total, orderId });
      });

      // Step 3: Wait for payment (with timeout)
      const paymentResult = await waitFor('payment-confirmed');

      if (paymentResult.amount !== total) {
        throw new Error('Payment amount mismatch');
      }

      compensate('create-payment-intent', async () => {
        await payments.refund(paymentIntent.id);
      });

      // Step 4: Confirm inventory
      await step('confirm-inventory', async () => {
        await inventory.confirm(reservation.id);
      });

      // Step 5: Create shipment
      const shipment = await step('create-shipment', async () => {
        return shipping.create({ orderId, items });
      });

      // Step 6: Send confirmation
      await step('send-confirmation', async () => {
        await notifications.sendOrderConfirmation(customerId, orderId);
      });

      // Step 7: Wait for shipping updates
      const shippingUpdate = await waitFor('shipping-update');

      if (shippingUpdate.status === 'delivered') {
        await step('send-delivery-notification', async () => {
          await notifications.sendDeliveryConfirmation(customerId, orderId);
        });
      }

      return {
        orderId,
        status: 'completed',
        trackingNumber: shipment.trackingNumber,
      };
    },
});

Time Helpers

Import time helpers for readable durations:

time-helpers.tsTypeScript
import { seconds, minutes, hours, days } from '@justscale/saga';

seconds(30);   // 30000ms
minutes(5);    // 300000ms
hours(2);      // 7200000ms
days(1);       // 86400000ms

// Use with sleep
await sleep(hours(24));