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 · Scaffold
- 2 · Catalog service
- 3 · Cart contracts
- 4 · Checkout saga
- 5 · Shipping webhook
- 6 · Storefront UI
- 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 theproductsservice and thecartcontract.aspire startis 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:
| Name | Type | Description |
|---|---|---|
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.
statusis 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
PaymentFailedhandler returnssagaCompensate(...); the matching.compensate('PaymentFailed', ...)handler transitions the same state machine tocancelledwithout 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.
| Name | Type | Description |
|---|---|---|
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.tsdefines state, a correlation key, the forward handlers, and aPaymentFailedcompensation branch. - [ ]
workers/jobs/process-payment.tspublishesPaymentCompleted/PaymentFailedback to the saga. - [ ]
triggers/checkout-payment-trigger.tsenqueuesprocess-paymentwithenqueueJob(...). - [ ]
ns-sagas list --registered --jsonlistsCheckoutSaga. - [ ] Publishing
OrderCreatedand invoking the checkout trigger yields an instance atstatus: 'paid'; publishingPaymentFaileddirectly exercises the isolated compensation branch and yields one atstatus: 'cancelled'. - [ ]
deno task checkpasses.
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
CheckoutSagabuilt withdefineSaga().state().correlate().on().build()— a durable state machine that walks order → payment, with aPaymentFailedcompensation branch that cancels the order. Inventory and shipping remain explicit future trigger/job legs. - A
process-paymentworker job (defineJobHandler,createSuccessResult/createFailureResult) that publishes results back to the saga withcreateSagaPublisher, 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.