A Process Across Components
An approved order is paid for, picked at the warehouse and shipped, and the customer is told at each step. If the warehouse does not have the items, the order is cancelled and the money goes back. Four components take part, and nothing stores a change in two of them at once.
The first idea is one operation that does it all:
// components/store.orders/operations/approve.ts — do not write this
import type { Context, State } from '../types/index.d.ts'
export async function transition (input: null, entry: State, context: Context) {
await context.remote.billing.wallets.debit({ /* … */ })
await context.remote.fulfilment.warehouse.pick({ /* … */ }) // minutes
await context.remote.notifications.send({ /* … */ })
entry.status = 'shipped'
} - The customer waits for minutes, and the request fails if any of the three is away.
- The process dies after
debit: the customer is charged for an order that is stillpending. - The warehouse refuses: the debit is already stored in another component, and nothing takes it back. There is no rollback across components.
§One step, one state, one announcement
Split the process into steps. Each step is one operation of one component: it stores its own change and stops. The change is announced as an event, and the next step is whatever receives it.
The order is the one store.orders keeps (Create a Component), with
the approve that debits the customer’s wallet in billing.wallets (Refuse a Request), and the cancel that
takes a reason and leaves it in TRAILERS (Announce a Change).
First, give every stage of the order a name. If nobody can say what state an order is in between
two steps, nobody can continue it or repair it. The entity gets two more statuses, the component pack and ship, and cancel now refuses only an order that has shipped:
# components/store.orders/manifest.toa.yaml
entity:
properties:
# customer, items and total stay
status:
type: string
enum: [pending, approved, picked, shipped, cancelled]
operations:
approve:
concurrency: retry
query: true
errors: [NOT_PENDING, INSUFFICIENT_FUNDS]
pack:
concurrency: retry
query: true
errors: [NOT_APPROVED]
ship:
concurrency: retry
query: true
errors: [NOT_PICKED]
cancel:
concurrency: retry
query: true
input:
type: object
properties:
reason: { type: string }
required: [reason]
errors: [SHIPPED] Then let each step follow from the one before:
client ──call──> store.orders.approve debits the wallet; status: approved
~ store.orders.approved
└──> fulfilment.warehouse.pick minutes at the warehouse
├─call─> store.orders.pack status: picked
└─call─> store.orders.cancel status: cancelled
~ store.orders.picked ────> notifications "your order is packed"
staff ───call──> store.orders.ship status: shipped
~ store.orders.cancelled
├──> billing.wallets.credit the money goes back
└──> notifications "your order was cancelled" approve is not changed: it calls billing.wallets.debit, because the
customer is waiting to hear whether the money was there. It sets approved and answers. Everything
after that happens without the customer waiting, and without store.orders knowing who does it.
store.orders.approved is the event Announce a Change declares; picked is declared the same way, by the status the order reaches, and cancelled is
shown below. notifications is the component of React to an Event.
The warehouse step reacts to the approval:
# components/fulfilment.warehouse/manifest.toa.yaml
name: warehouse
namespace: fulfilment
operations:
pick:
input:
type: object
properties:
order: { type: string }
items: { type: array }
required: [order, items]
receivers:
store.orders.approved: pick
configuration:
schema:
type: object
properties:
url: { type: string, format: uri }
required: [url] # context.toa.yaml — added under `configuration`
configuration:
fulfilment.warehouse:
url: https://warehouse.example.com // components/fulfilment.warehouse/receivers/store.orders.approved.ts
import type { Entity as Order } from '@components/store.orders'
export function request (order: Order) {
return { input: { order: order.id, items: order.items } }
} // components/fulfilment.warehouse/operations/pick.ts
import type { Context, PickInput } from '../types/index.d.ts'
export async function effect (input: PickInput, context: Context) {
const response = await context.fetch(`${context.configuration.url}/picks/${input.order}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ items: input.items })
})
if (response.status === 409) {
await response.body.cancel()
await context.remote.store.orders.cancel({
query: { id: input.order },
input: { reason: 'OUT_OF_STOCK' }
})
return
}
if (!response.ok) throw new Error(`Warehouse answered ${response.status}`)
await response.body.cancel()
const packed = await context.remote.store.orders.pack({ query: { id: input.order } })
if (packed instanceof Error)
await context.fetch(`${context.configuration.url}/picks/${input.order}`, { method: 'DELETE' })
} pick is an effect: it asks the
warehouse’s system with context.fetch, at the address the Context
sets. The receiver’s Order is the type generated from the entity of store.orders.
pick keeps nothing of its own, so it reports by calling the order back, under the id it was
given. A step that does keep state reports the way store.orders does, by announcing its change,
and the order receives it:
# components/store.orders/manifest.toa.yaml — if `fulfilment.shipments` booked the courier
receivers:
fulfilment.shipments.booked: ship
fulfilment.shipments.refused: cancel §The owner of the process decides
The order’s status is written only by operations of store.orders, and each of them checks the
state it finds. The other components report facts — “picked”, “out of stock” — and the order
decides what they mean:
// components/store.orders/operations/pack.ts
import type { State } from '../types/index.d.ts'
export async function transition (input: null, entry: State) {
if (entry.status === 'picked') {
entry.DISCARD = true // reported again: nothing to write
return entry
}
if (entry.status !== 'approved') return new Error('NOT_APPROVED')
entry.status = 'picked'
return entry
} // components/store.orders/operations/cancel.ts
import type { CancelInput, State } from '../types/index.d.ts'
export async function transition (input: CancelInput, entry: State) {
if (entry.status === 'cancelled') {
entry.DISCARD = true
return entry
}
if (entry.status === 'shipped') return new Error('SHIPPED')
if (entry.status !== 'pending') entry.TRAILERS.paid = entry.total // it was paid for
entry.status = 'cancelled'
entry.TRAILERS.reason = input.reason
return entry
} Three answers, on purpose: a report made twice is accepted and writes nothing; a step that no longer applies is refused with an error; anything else moves the order on. Which to use when is in Saying No.
ship is written like pack, for the statuses picked and shipped and the error NOT_PICKED.
What cancel leaves in TRAILERS is not stored with the order:
it travels with the event of that write.
§A failure is one more step forward
Nothing is rolled back. An order that cannot be packed does not return to pending with its
debit erased: it moves to a new state, cancelled, and that change is announced like any other.
Whoever did something that now has to be undone reacts to it with a compensation: an ordinary
operation that makes up for an earlier step.
// components/store.orders/events/cancelled.ts
import type { Event } from '@toa.io/core/types'
import type { Entity } from '../types/index.d.ts'
export function condition (event: Event<Entity>) {
return event.origin?.status !== 'cancelled' && event.state.status === 'cancelled'
}
export function payload (event: Event<Entity, { reason: string, paid?: number }>) {
return {
id: event.state.id,
customer: event.state.customer,
total: event.state.total,
status: event.state.status,
reason: event.trailers?.reason,
refund: event.trailers?.paid ?? 0,
version: event.state.VERSION
}
} # components/billing.wallets/manifest.toa.yaml
operations:
credit:
concurrency: retry
once: true
query: true
# its input: amount, type, ref
receivers:
store.orders.cancelled: credit credit, with its input and its code, is the operation of A Wallet and Its Ledger; here it gets one more receiver.
// components/billing.wallets/receivers/store.orders.cancelled.ts
export function condition (order: { refund: number }) {
return order.refund > 0
}
export function request (order: { id: string, customer: string, refund: number }) {
return {
query: { id: order.customer },
input: { amount: order.refund, type: 'refund', ref: order.id }
}
} The same event serves a cancellation by the warehouse, by the customer and by staff, and store.orders does not know that money is involved. The refund is recorded in the wallet’s
history as a second movement; the first one is not erased, because it happened.
Try it with a stub in place of the warehouse
that answers 409: restart, create an order whose customer has
the money, approve it by its id, wait, and look.
$ npm run restart
$ npx toa call store.orders.approve "{ query: { id: '7a1c5e0c2b8f4d0e9a3b6c1d2e4f5a6b' } }"
$ npx toa call store.orders.observe "{ query: { id: '7a1c5e0c2b8f4d0e9a3b6c1d2e4f5a6b' } }" The first command prints the order as approved. A moment later the second prints it as cancelled, and the wallet has its balance back.
§Why not one operation
Let each component store its own step and announce it; let the next step react; make a failure one more step, with its own name. An event is recorded together with the change it announces, so a step that was stored always gets its follow-up, even if the process dies the next instant — and a follow-up that fails is tried again instead of being lost (Transactional Outbox). A chain of calls in one operation promises neither.
Use a call inside the process only where a step cannot decide without the answer, as approve does with the debit.
Where the steps are several requests made by one effect, and nobody else needs to see the states between them — reserve, book a courier, send the tracking number — one resumable effect is less to write: Write a Multi-Step Workflow. It suits a short script of outside calls; it does not give the order statuses others can read, select by or react to.
§What can still go wrong
A step runs twice. An event can arrive again, and an effect that threw is run again from the
top. pick uses PUT with the order’s id, so the warehouse sees the same pick again, not a
second one; pack and cancel discard a report they already have; approve run again calls debit with the order’s id, and the wallet takes that payment once; credit adds and is reached
by an event, so it declares once. See The Same Message Twice.
Two things happen to the order at once. The customer cancels while the warehouse is picking.
Both are transitions of the same order with concurrency: retry, so one is stored and the other
runs again on what it left (Two Writers at Once): cancel wins, and the
late pack is answered NOT_APPROVED. That is why pick checks the reply and puts the items
back. Every step that acts outside has to
expect that the order moved on while it worked.
A step refuses and tells nobody. Nobody reads the reply of an operation reached by an event.
If pick merely returned an error for missing stock, the order would stay approved for good.
A refusal inside a process is written into state — here, by calling cancel — so that it is
visible and announced.
A step never finishes. The warehouse is down for a day; pick keeps throwing and is in the
end set aside for an operator. The order
stays approved, which is true and visible. Add a sweep that finds orders that have been in one
state for too long: Work on the Clock.
A step is started before the change is stored. Start the next step from the event, not from
inside the transition that makes the change. A call or a task made there goes out even if the
write is then refused or the transition is run again, and the next step would work on an order
that is not approved yet, or never will be: Reply Before the Work Is Done.
Undoing has to be repeatable too. Where a cancellation takes several outside actions, make them one operation that can be run again from the top, and remove last whatever the earlier removals need.
§Checklist
- Every stage between two steps has a status of its own, failures included.
- One component owns the process state; the others report to it by an event or by a call back.
- Each step checks the state it finds: a repeat is discarded, a late step is refused.
- A failure moves forward as a new status, and compensations react to it.
- A refusal inside a step is written to state, never only returned.
- Every step that acts outside is safe to run twice and checks whether it is still wanted.
- Something looks for processes that stopped moving.