Skip to content

Saga Pattern

The saga pattern coordinates multi-step operations across services. If any step fails, previously completed steps are automatically compensated (rolled back) in reverse order.

import { saga, createDefaultSagaStore } from '@venturekit/runtime';
const store = await createDefaultSagaStore();
const createOrder = saga('create-order', [
{
name: 'reserve-inventory',
execute: async (ctx) => {
const reservation = await reserveItems(ctx.items);
return { ...ctx, reservationId: reservation.id };
},
compensate: async (ctx) => {
await releaseReservation(ctx.reservationId);
},
},
{
name: 'charge-payment',
execute: async (ctx) => {
const charge = await chargeCard(ctx.paymentMethod, ctx.total);
return { ...ctx, chargeId: charge.id };
},
compensate: async (ctx) => {
await refundCharge(ctx.chargeId);
},
},
{
name: 'send-confirmation',
execute: async (ctx) => {
await sendEmail(ctx.email, 'Order confirmed');
return ctx;
},
// No compensate — email can't be unsent
},
], { store });
const result = await createOrder.run({
items: [{ sku: 'WIDGET-1', qty: 2 }],
paymentMethod: 'pm_xxx',
total: 4999,
email: 'alice@example.com',
});
if (result.success) {
console.log('Order created:', result.context.chargeId);
} else {
console.error('Order failed:', result.record.error);
}

Use parallel() to execute independent steps concurrently. If any step in the group fails, all completed steps in the group are compensated.

import { saga, parallel } from '@venturekit/runtime';
const checkout = saga('checkout', [
// These two run at the same time
parallel('validate', [
{
name: 'check-inventory',
execute: async (ctx) => {
const available = await checkStock(ctx.items);
return { ...ctx, stockAvailable: available };
},
},
{
name: 'verify-payment',
execute: async (ctx) => {
const valid = await verifyPaymentMethod(ctx.paymentMethod);
return { ...ctx, paymentVerified: valid };
},
},
]),
// This runs after both parallel steps complete
{
name: 'charge',
execute: async (ctx) => {
const charge = await chargeCard(ctx.paymentMethod, ctx.total);
return { ...ctx, chargeId: charge.id };
},
compensate: async (ctx) => {
await refundCharge(ctx.chargeId);
},
},
]);

Parallel step contexts are shallow-merged — each parallel step can add new keys to the context and they’ll all be available to subsequent steps.

Pass a traceId to track the entire saga execution across logs:

const result = await createOrder.run(context, {
traceId: ctx.trace?.traceId, // from the incoming request
});
// All step logs include: [saga:create-order][trace:abc-123]

Or set a default traceId for all executions:

const mySaga = saga('order', steps, {
store,
traceId: 'default-trace-id',
});

When a store is configured, saga state is persisted after every step. If a saga fails, you can resume it later — already-completed steps are skipped (idempotent execution).

import { saga, createDynamoDBSagaStore } from '@venturekit/runtime';
const store = await createDynamoDBSagaStore({
tableName: 'venturekit-sagas', // default
ttlSeconds: 604800, // 7 days (default)
});
const mySaga = saga('create-order', steps, { store });

createDefaultSagaStore() picks the right backend automatically:

  • Lambda: DynamoDB
  • Local dev: In-memory
import { createDefaultSagaStore } from '@venturekit/runtime';
const store = await createDefaultSagaStore();
// First attempt — assign an explicit ID
const result = await mySaga.run(context, { id: 'order-abc-123' });
if (!result.success) {
// Fix the external issue, then resume:
const resumed = await mySaga.resume('order-abc-123');
// Skips already-completed steps, retries from the failure point
console.log(resumed.record.attempts); // 2
}

Every step receives the accumulated context and returns an updated version. Each step can add new keys or modify existing ones:

const mySaga = saga('order', [
{
name: 'reserve',
execute: async (ctx) => {
const res = await reserve(ctx.items);
return { ...ctx, reservationId: res.id }; // adds reservationId
},
},
{
name: 'charge',
execute: async (ctx) => {
// ctx.reservationId is available here from the previous step
const charge = await chargeCard(ctx.paymentMethod, ctx.total);
return { ...ctx, chargeId: charge.id }; // adds chargeId
},
},
]);

The final result.context contains all keys accumulated from every step.

After the saga completes, you can access each step’s individual output:

const result = await mySaga.run(initialContext);
// Access individual step outputs
const reserveOutput = result.steps['reserve'].output;
const chargeOutput = result.steps['charge'].output;
console.log(reserveOutput.reservationId); // from the reserve step
console.log(chargeOutput.chargeId); // from the charge step
// Check if a step was async (fire-and-forget)
if (result.steps['send-email'].async) {
console.log('Email was fired asynchronously');
}

For parallel groups, each sub-step has its own entry in result.steps.

Mark a step as async: true to run it without waiting. The saga immediately moves to the next step. This is useful for side-effects that don’t affect the main flow:

const createOrder = saga('create-order', [
{
name: 'reserve-inventory',
execute: async (ctx) => {
const res = await reserve(ctx.items);
return { ...ctx, reservationId: res.id };
},
compensate: async (ctx) => {
await release(ctx.reservationId);
},
},
{
name: 'charge-payment',
execute: async (ctx) => {
const charge = await chargeCard(ctx.paymentMethod, ctx.total);
return { ...ctx, chargeId: charge.id };
},
compensate: async (ctx) => {
await refund(ctx.chargeId);
},
},
// Fire-and-forget: send confirmation email without blocking
{
name: 'send-confirmation',
async: true,
execute: async (ctx) => {
await sendEmail(ctx.email, 'Order confirmed', { orderId: ctx.chargeId });
return ctx;
},
// No compensate needed — async steps are NOT compensated
},
// Fire-and-forget: trigger analytics
{
name: 'track-analytics',
async: true,
execute: async (ctx) => {
await analytics.track('order.created', { orderId: ctx.chargeId });
return ctx;
},
},
]);
  • Does not block: The saga moves to the next step immediately
  • Does not merge context: The step’s return value is ignored for context accumulation
  • Not compensated: If the saga fails later, async steps are skipped during compensation (they may still be running)
  • Errors are logged but ignored: An async step failure does not fail the saga
  • Good for: Notifications, analytics, audit logging, cache warming, webhooks
  1. Make compensations idempotent — they may be called more than once on retry
  2. Log compensation actions — use the onCompensating callback for observability
  3. Handle partial failures — if compensation itself fails, the saga enters 'failed' status
  4. Order matters — compensations run in reverse order (last completed → first completed)
  5. Not all steps need compensation — logging, emails, and read-only operations can skip it
const mySaga = saga('order', steps, {
store,
onStepComplete: (name, index) => {
console.log(`Step ${name} completed (${index})`);
},
onCompensating: (name, index) => {
console.log(`Compensating step ${name} (${index})`);
},
onFailed: (error, record) => {
// Alert, send to dead-letter queue, etc.
console.error(`Saga ${record.id} failed: ${error.message}`);
},
});
pending → running → completed
↘ compensating → compensated
↘ failed (compensation error)
StatusMeaning
runningExecuting steps
completedAll steps succeeded
compensatingA step failed, running compensations
compensatedAll compensations succeeded
failedA compensation failed — manual intervention needed

The saga store table needs:

  • Partition key: pk (String)
  • TTL attribute: ttl (Number)
Terminal window
aws dynamodb create-table \
--table-name venturekit-sagas \
--attribute-definitions AttributeName=pk,AttributeType=S \
--key-schema AttributeName=pk,KeyType=HASH \
--billing-mode PAY_PER_REQUEST
aws dynamodb update-time-to-live \
--table-name venturekit-sagas \
--time-to-live-specification "Enabled=true, AttributeName=ttl"