Skip to main content
0.0.x

Durable checkout

In chapter 3 you designed the cart contract. Checkout is what turns a cart into an order — and it is the one place in a shop where a crash mid-flight costs real money. A naive async function that charges a card, reserves inventory, then books shipment is a liability: if the process dies after the charge but before the reservation, you have taken money and shipped nothing. This chapter rebuilds checkout as a durable saga — a state machine that checkpoints its progress, reacts to payment and inventory messages, and runs a compensation path when a step fails.

  1. 1 · Scaffold
  2. 2 · Catalog service
  3. 3 · Cart contracts
  4. 4 · Checkout saga
  5. 5 · Shipping webhook
  6. 6 · Storefront UI
  7. 7 · Deploy

What you will build

You will add the runtime plugins checkout depends on, then author a CheckoutSaga with defineSaga(...): typed per-instance state, a correlation key, and message handlers that walk a checkout from OrderCreated through payment toward fulfillment. You will also author the process-payment worker job that a checkout trigger enqueues, and you will wire the failure path so a declined payment cancels the order instead of stranding it. By the end you can drive a checkout to a paid order and watch a failed payment compensate to cancelled — both observable on the Sagas API.

Before you begin

You should have finished chapter 3, so:

  • my-shop/ has the products service and the cart contract.
  • aspire start is up (the dashboard answers at https://localhost:18888). The saga registry and durable instance store both depend on Aspire-managed resources — Deno KV for the registry, and either KV or Postgres for instance state.

Step 1 — Add the checkout runtime plugins

Checkout spans four official runtime plugins, and you install each one explicitly: sagas (the durable workflow), workers (background jobs), triggers (the supported worker enqueue boundary), and streams (the durable transport). Add them from the project root, with samples so you have working modules to adapt:

netscript plugin install worker --name workers --samples
netscript plugin install saga --name sagas --samples
netscript plugin install trigger --name triggers --samples
netscript plugin install stream --name streams --samples

Each plugin lands at its canonical location (plugins/sagas/, plugins/workers/, plugins/streams/), and netscript.config.ts is updated to reference each mod.ts. A slimmer top-level staging copy (e.g. sagas/) is also created for the background processor — you author against plugins/<name>/.

Confirm they registered:

netscript plugin list

You should see workers, sagas, triggers, and streams in the list.

Step 2 — Read the saga builder

NetScript sagas are authored with a fluent builder imported from @netscript/plugin-sagas-core. Each call narrows the saga's type and configuration; .build() produces the definition the runtime consumes. The methods you will use:

SagaBuilder methods (from @netscript/plugin-sagas-core)
NameTypeDescription
defineSaga(id) start the chain Begins a saga definition with a stable id used in the registry and instance keys.
.durability(tier) persistence tier Selects the durability tier (defaults to T1). The persisted tier checkpoints instance state so an in-flight workflow survives a restart.
.state(initial) per-instance state Declares the state shape and its initial value. Every correlated instance gets its own copy. Must come before any handler.
.correlate(fn) instance routing Extracts the correlation key from an incoming message so it reaches the right instance — e.g. correlate by orderId.
.on(type, handler) message handler Subscribes to a message type. The handler reads state + message and returns an array of effects.
.compensate(type, handler) compensation handler Registers a handler for a FAILED event type — the undo path. Same shape as .on(), reserved for compensation.
.build() finalize Produces the frozen SagaDefinition the runner executes. Requires at least one handler.

The saga DSL also exports send(target, payload) for cascaded messages handled by registered saga definitions. It does not call a service, enqueue a worker job, or run a task. This payment leg does not need an internal cascade: its trigger uses enqueueJob(...) for durable worker dispatch, and the worker publishes a typed result back to the saga.

Step 3 — Scaffold the checkout saga

Start with the saga definition and config scaffold:

The add-saga verb uses the spaced add saga shell syntax:

ns-sagas add saga checkout --message-type=OrderCreated --durability=t1 --topic=checkout

The command writes sagas/checkout-saga.ts plus sagas/checkout.config.ts, including a normal handler and a compensation-handler skeleton, and refreshes the saga registry. Extend that generated definition with the checkout state and lifecycle below. It correlates by orderId, starts pending, and walks the lifecycle. When payment fails, the forward handler returns a sagaCompensate(...) effect and the matching .compensate(...) branch records cancellation.

// sagas/checkout-saga.ts
import { defineSaga, sagaCompensate } from '@netscript/plugin-sagas-core';
import type { SagaCorrelationKey, SagaState } from '@netscript/plugin-sagas-core/domain';

type OrderStatus =
  | 'pending'
  | 'payment_pending'
  | 'paid'
  | 'cancelled';

// Per-instance checkout state. Runtime metadata is handled for you.
interface CheckoutState extends SagaState {
  orderId: string;
  customerId: string;
  status: OrderStatus;
  items: Array<{ productId: string; quantity: number }>;
  total: number;
  transactionId?: string;
  cancelReason?: string;
}

const initialState: CheckoutState = {
  orderId: '',
  customerId: '',
  status: 'pending',
  items: [],
  total: 0,
};

export const checkoutSaga = defineSaga('CheckoutSaga')
  .state(initialState)
  // OrderCreated records the workflow. The trigger below owns worker dispatch.
  .on('OrderCreated', (saga, event) => {
    const msg = event.payload as { orderId: string; customerId: string; items: CheckoutState['items']; total: number };
    saga.state = {
      ...saga.state,
      orderId: msg.orderId,
      customerId: msg.customerId,
      items: msg.items,
      total: msg.total,
      status: 'payment_pending',
    };
    return [];
  })

  // The payment worker publishes this result back to the saga.
  .on('PaymentCompleted', (saga, event) => {
    if (saga.state.status !== 'payment_pending') return [];
    const msg = event.payload as { transactionId: string };
    saga.state = { ...saga.state, status: 'paid', transactionId: msg.transactionId };
    return [];
  })

  // Request compensation using the message type registered below.
  .on('PaymentFailed', (saga, event) => {
    if (saga.state.status !== 'payment_pending') return [];
    const msg = event.payload as { orderId: string; reason: string };
    return [sagaCompensate({ type: 'PaymentFailed', payload: msg }, msg.reason)];
  })

  // The default durable runtime routes the returned effect here.
  .compensate('PaymentFailed', (saga, event) => {
    const msg = event.payload as { orderId: string; reason: string };
    saga.state = {
      ...saga.state,
      status: 'cancelled',
      cancelReason: `Payment failed: ${msg.reason}`,
    };
    return [];
  })

  // Route every handled message to the instance whose orderId matches.
  .correlate((message) =>
    String((message.payload as { orderId?: string }).orderId ?? '') as SagaCorrelationKey
  )

  .build();

export default checkoutSaga;

Read the shape, not the line count:

  • State is a typed state machine. status is a union; every handler guards on it (if (saga.state.status !== 'paid') return []) so a redelivered or out-of-order message is a no-op, not a corruption. Durable workflows are state machines is a NetScript axiom, not a slogan.
  • This saga does not dispatch worker work. send(...) is available for messages handled by a registered saga definition, but no such cascade is needed in this payment leg. The trigger below owns the explicit cross-plugin enqueue boundary.
  • Compensation is an explicit effect and registered branch. The forward PaymentFailed handler returns sagaCompensate(...); the matching .compensate('PaymentFailed', ...) handler transitions the same state machine to cancelled without inventing another unhandled message.

Step 4 — Enqueue payment through the triggers API

Use the supported triggers action to turn checkout ingress into durable background work. The generated checkout-payment webhook can be reduced to this definition (use a signed verifier for a real public endpoint; memory keeps this local tutorial runnable):

// triggers/checkout-payment-trigger.ts
import { defineWebhook, enqueueJob } from '@netscript/plugin-triggers-core/builders';
import type { JobDefinition } from '@netscript/plugin-workers-core';

const processPaymentJob = {
  id: 'process-payment' as JobDefinition<'process-payment'>['id'],
  name: 'Process payment',
  topic: 'default',
} satisfies JobDefinition<'process-payment'>;

export default defineWebhook(
  (event) => Promise.resolve([enqueueJob(processPaymentJob, { payload: event.payload.body })]),
  { id: 'checkout-payment', path: 'checkout/payment', verifier: 'memory' },
);

Post the same order payload to this webhook when you publish OrderCreated. The saga records and emits its internal CheckoutPaymentRequested message; the trigger independently and durably enqueues process-payment. This explicit choreography avoids pretending that a saga cascade is a workers transport.

Step 5 — Author the payment worker job

The trigger enqueues process-payment; the worker job does the work and reports back. It publishes PaymentCompleted (or PaymentFailed) to the saga with createSagaPublisher, closing the explicit cross-plugin choreography.

// workers/jobs/process-payment.ts
import {
  createFailureResult,
  createSuccessResult,
  defineJobHandler,
} from '@netscript/plugin-workers-core';
import { createSagaPublisher } from '@netscript/plugin-sagas/runtime';
import { z } from 'zod';
import type { OrderSagaMessage } from '../saga-message-types.ts';

// Publishes results back to the saga bus.
const sagaPublisher = createSagaPublisher<OrderSagaMessage>();

const PayloadSchema = z.object({
  orderId: z.string().min(1),
  amount: z.number().positive(),
});

const handler = defineJobHandler(async (ctx) => {
  const { orderId, amount } = PayloadSchema.parse(ctx.payload ?? {});

  try {
    // ... charge the card via your provider (mock here) ...
    const transactionId = `txn_${Date.now()}`;

    // Tell the saga payment succeeded — it advances to paid.
    await sagaPublisher.publish({ type: 'PaymentCompleted', payload: { orderId, transactionId } });

    return createSuccessResult({ orderId, transactionId, amount });
  } catch (error) {
    const reason = error instanceof Error ? error.message : String(error);

    // Tell the saga payment failed — its compensation branch cancels the order.
    await sagaPublisher.publish({ type: 'PaymentFailed', payload: { orderId, reason } });

    return createFailureResult(`${reason} (orderId: ${orderId})`);
  }
});

export default Object.assign(handler, { id: 'process-payment' });

The contract between the two halves is the message type string. The job publishes PaymentCompleted / PaymentFailed; the saga .on('PaymentCompleted', …) and .on('PaymentFailed', …) listen for exactly those. There is no shared function call — they are isolated background processors joined only by the message traveling through the streams transport. Keep the strings identical on both sides.

Worker job primitives (from @netscript/plugin-workers-core)
NameTypeDescription
defineJobHandler(fn) define a job Wraps an async handler that receives a typed ctx (payload, logging, tracing) and returns a result.
createSuccessResult(data) success The handler's return for a completed job; carries result data.
createFailureResult(reason) failure The handler's return for a failed job; the message string is recorded on the execution.
createSagaPublisher() from @netscript/plugin-sagas/runtime Publishes typed messages onto the saga bus so a running saga can react — how the job reports back.

Step 6 — Type-check the workflow

The Sagas API service lists sagas from a KV-backed registry, and the scaffold's saga runtime registers your built definition on startup. Because aspire start already brings the sagas processor and API up together, you do not start anything by hand — your saga is picked up when the orchestrated app (re)starts. First, prove it compiles against the builder's generic signatures:

deno task check

A clean check means defineSaga, .state(), .correlate(), .on(), and .build() all line up with the message and state types you declared, and that the worker job's publish calls match the saga message types.

Verify your progress

With Aspire up, confirm the saga registered through the Sagas API (the CLI resolves its allocated endpoint for you):

ns-sagas list --registered --json

You should see CheckoutSaga in the list, with OrderCreated, PaymentCompleted, and PaymentFailed among its handled message types. Now drive an instance directly by publishing messages to the saga bus — ns-sagas publish sends { type, payload }, and the saga correlates on payload.orderId. Start an order, then complete its payment:

# 1. Open the checkout — the saga records payment_pending and emits CheckoutPaymentRequested.
ns-sagas publish OrderCreated \
  --payload='{ "orderId": "ord_1001", "customerId": "cust_1001", "items": [{ "productId": "1", "quantity": 2 }], "total": 4999 }' \
  --correlation-key=ord_1001

# 2. Enqueue the process-payment worker through the supported triggers ingress.
#    Take <triggers-endpoint> from the Aspire resource list.
curl -X POST <triggers-endpoint>/api/v1/webhooks/checkout/payment \
  -H 'content-type: application/json' \
  -d '{ "orderId": "ord_1001", "amount": 4999 }'

The registered worker resolves and runs process-payment, which publishes PaymentCompleted back to the correlated saga instance. No direct PaymentCompleted command is needed on the happy path.

Inspect that instance and confirm it reached paid:

ns-sagas list --instances --saga=CheckoutSaga --json

Now prove compensation. Open a second order and fail its payment — the PaymentFailed branch walks the state machine to cancelled:

ns-sagas publish OrderCreated \
  --payload='{ "orderId": "ord_2002", "customerId": "cust_2002", "items": [{ "productId": "1", "quantity": 1 }], "total": 1999 }' \
  --correlation-key=ord_2002

ns-sagas publish PaymentFailed \
  --payload='{ "orderId": "ord_2002", "reason": "card_declined" }' \
  --correlation-key=ord_2002

ns-sagas list --instances --saga=CheckoutSaga --json

The first instance shows status: 'paid' carrying its transactionId; the second shows status: 'cancelled' carrying the cancelReason your compensation branch stamped. (The forward path can continue once you separately author inventory and shipment triggers/jobs. Saga send(...) cascades remain internal saga-bus messages; this chapter stops at the implemented payment leg, so paid is checkout's observable checkpoint.)

  • [ ] The workers, sagas, triggers, and streams plugins are installed and registered.
  • [ ] checkout-saga.ts defines state, a correlation key, the forward handlers, and a PaymentFailed compensation branch.
  • [ ] workers/jobs/process-payment.ts publishes PaymentCompleted / PaymentFailed back to the saga.
  • [ ] triggers/checkout-payment-trigger.ts enqueues process-payment with enqueueJob(...).
  • [ ] ns-sagas list --registered --json lists CheckoutSaga.
  • [ ] Publishing OrderCreated and invoking the checkout trigger yields an instance at status: 'paid'; publishing PaymentFailed directly exercises the isolated compensation branch and yields one at status: 'cancelled'.
  • [ ] deno task check passes.

What you built

  • The workers, sagas, triggers, and streams runtime plugins — the workflow, its explicit worker enqueue boundary, its jobs, and the transport between them.
  • A CheckoutSaga built with defineSaga().state().correlate().on().build() — a durable state machine that walks order → payment, with a PaymentFailed compensation branch that cancels the order. Inventory and shipping remain explicit future trigger/job legs.
  • A process-payment worker job (defineJobHandler, createSuccessResult / createFailureResult) that publishes results back to the saga with createSagaPublisher, closing the choreography.
  • A workflow observable as instances on the Sagas API.

Checkout now survives restarts and records a durable cancellation when payment fails. The next chapter adds a verified webhook for outside providers.