II Business Logic

Protect an Invariant

A cancelled order stays cancelled — whichever operation touches it, including ones nobody has written yet and the inherited transit. A rule that must hold for every write of a component is a guard.

§Writing a guard

A guard is a file in the component’s guards directory that exports guard. The orders are those of store.orders, the component Create a Component writes; an order’s status is pending, approved or cancelled:

// components/store.orders/guards/final.ts
import type { Entity } from '../types/index.d.ts'

export function guard (order: Entity, origin: Entity | null) {
  return origin?.status !== 'cancelled' || order.status === 'cancelled'
}
Argument
firstThe state about to be stored.
originThe state it replaces; null for a new object.
contextThe operation context.

Return true to let the change through. Nothing is declared in the manifest.

Every guard of the component is checked before each write. A guard is synchronous: it decides from the two states it is given.

§When a guard refuses

Two operations to try it with: cancel, the transition Write Operations writes, which takes a reason, and the inherited transit, declared so that it may set status:

# components/store.orders/manifest.toa.yaml
operations:
  cancel:
    concurrency: retry
    query: true
    input:
      type: object
      properties:
        reason: { type: string }
      required: [reason]
    errors: [NOT_PENDING]
  transit:
    concurrency: retry
    input:
      type: object
      properties:
        status: { type: string }

Cancel an order, then try to bring it back with transit:

$ npm run restart
$ npx toa call store.orders.cancel "{ query: { id: '3f7a9d2c1b4e4a6f8c5d0e1b2a3c4d5e' }, input: { reason: 'out of stock' } }"
$ npx toa call store.orders.transit "{ query: { id: '3f7a9d2c1b4e4a6f8c5d0e1b2a3c4d5e' }, input: { status: 'pending' } }"

The change is not stored. The second command prints an exception to stderr and exits with 1; the exception’s code is 213, its message is the guard’s name — final — and its cause is the state that was refused. Over HTTP it is a 500.

A guard is a safety net, not a way to answer callers. A refusal a caller should expect is an error returned by the operation:

// components/store.orders/operations/reopen.ts
import type { State } from '../types/index.d.ts'

export async function transition (input: null, entry: State) {
  if (entry.status === 'cancelled')
    return new Error('CANCELLED')

  entry.status = 'pending'
}

The guard is what still holds when somebody adds another operation next year and forgets the check.

§Guard, schema or operation

The rule is aboutPut it in
one property’s shape or rangethe entity schema
several properties of one state: a wallet’s held <= balancea guard
the state before and after: total only decreasesa guard
who may do it, or anything outside the objectthe operation
Search
Search the documentation