VII Patterns

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 still pending.
  • 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.
Search
Search the documentation