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
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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));