II Business Logic

Announce a Change

When an order is approved, the rest of the shop must learn about it — a receipt is sent, loyalty points are credited — and so must the warehouse system outside the application. The orders component does not call any of them. It declares that “an order became approved” is an event, and whoever cares subscribes.

An operation never publishes an event. You describe which change is one, and it is published whenever such a change is stored.

§Declaring an event

An event is a file in the component’s events directory. The file name is the event’s name, and it exports a condition. The orders are those of store.orders, the component Create a Component writes, and its approve sets an order’s status to approved:

// components/store.orders/events/approved.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 !== 'approved' && event.state.status === 'approved'
}

After every stored change of an order the condition is called. Where it returns true, store.orders.approved is published, carrying the order’s new state. Nothing is added to the manifest.

To see it arrive, bind a receiver to it and approve an order:

$ npm run restart
$ npx toa call store.orders.approve "{ query: { id: '5b1ad5d8a4c54e1dbb2a4a0b6e1f0c11' } }"

The argument describes the change:

stateThe object after the change.
originThe object before it; null if it was just created.
inputThe input of the operation that made the change.
trailersWhat the operation left in TRAILERS; see below.

The condition is evaluated for a change made by any operation — yours, inherited or forwarded. approved is published however the order became approved.

An event file with no condition is published on every change.

The second argument is the operation context, for a condition that depends on configuration.

§Payload

By default the payload is the object’s new state. Export payload to send something else:

// components/store.orders/events/approved.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 !== 'approved' && event.state.status === 'approved'
}

export function payload (event: Event<Entity>) {
  return {
    id: event.state.id,
    customer: event.state.customer,
    total: event.state.total,
    version: event.state.VERSION
  }
}

A narrow payload is a smaller promise to subscribers. Keep the id, and keep VERSION if subscribers need to tell a repeat.

§Trailers

The cancelled event must say why the order was cancelled, and the order does not keep the reason. A transition writes such a fact into TRAILERS:

// 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 !== 'pending')
    return new Error('NOT_PENDING')

  entry.status = 'cancelled'
  entry.TRAILERS.reason = input.reason
}
// 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, { reason: string }>) {
  return event.origin?.status !== 'cancelled' && event.state.status === 'cancelled'
}

export function payload (event: Event<Entity, { reason: string }>) {
  return { id: event.state.id, reason: event.trailers?.reason }
}
# components/store.orders/manifest.toa.yaml
operations:
  cancel:
    concurrency: retry
    query: true
    input:
      type: object
      properties:
        reason: { type: string }
      required: [reason]
    errors: [NOT_PENDING]
$ npm run restart
$ npx toa call store.orders.cancel "{ query: { id: '3f7a9d2c1b4e4a6f8c5d0e1b2a3c4d5e' }, input: { reason: 'out of stock' } }"

publishes store.orders.cancelled with { id: '3f7a…', reason: 'out of stock' }.

A trailer can also be the event. billing.wallets.debit, the operation Refuse a Request writes, takes amount and ref — what the money is for — and keeps what it took under ref in the wallet’s paid, so that asked twice for the same thing it takes once: the repeat sets DISCARD. Here it announces every debit of a customer’s wallet with its amount:

// components/billing.wallets/operations/debit.ts
import type { DebitInput, State } from '../types/index.d.ts'

export async function transition (input: DebitInput, entry: State) {
  if (entry.paid[input.ref] !== undefined) {
    entry.DISCARD = true // asked again for the same thing: nothing is stored or announced

    return { balance: entry.balance }
  }

  if (entry.balance < input.amount)
    return new Error('INSUFFICIENT_FUNDS')

  entry.balance -= input.amount
  entry.paid[input.ref] = input.amount
  entry.TRAILERS.amount = input.amount

  return { balance: entry.balance }
}
// components/billing.wallets/events/debited.ts
import type { Event } from '@toa.io/core/types'
import type { Entity } from '../types/index.d.ts'

export const condition = (event: Event<Entity, { amount: number }>) =>
  event.trailers?.amount !== undefined

export const payload = (event: Event<Entity, { amount: number }>) => ({
  wallet: event.state.id,
  amount: event.trailers?.amount,
  balance: event.state.balance,
  key: `${event.state.id}:${event.state.VERSION}`
})

The key is worth copying: an object’s id together with its VERSION names one write. A subscriber that keeps a ledger line per debit stores the line under that key, and a repeated event writes the same line again instead of a second one.

Rules for trailers:

  • TRAILERS is never stored with the object, never returned, and cannot be declared in the entity schema.
  • Values must be serializable: a function, a stream or a class instance does not survive.
  • Only a transition has them. An assignment has no object to hold trailers, so its events have an origin and no trailers.

§Inherited events

Every component with an entity already has created, updated, deleted and sync (Use the Inherited Operations). An event file of the same name replaces the inherited one.

§Consumers outside the application

Inside the application, an event is published once some component has a receiver for it; nothing else is needed. An event consumed by another system — the warehouse — has no receiver to be found by, so name it in the Context:

# context.toa.yaml
events:
  - store.orders.approved # the warehouse system listens

An event that has neither a receiver nor a line here is not published when the application is deployed. On a local run — npm start, a test — every event is published.

§What a subscriber must expect

An event is published only for a change that was stored: an operation that returned an error or discarded its change announces nothing. An event may arrive more than once, and two events may arrive in either order. Write whatever handles them accordingly: React to an Event, Handle Repeated Calls. The promises behind this are in the model: Transactional Outbox.

§Writing conditions and payloads

  • Compare origin with state. state.status === 'approved' alone is true on every later change of an approved order, and the event would be published each time.
  • origin is null for a new object. Use event.origin?.status.
  • Keep them pure. Decide from the argument only: no clock, no counter, no calls. The same change must always give the same event.
  • Never throw. A condition or a payload function that throws holds back every event of that change until a fixed version is deployed; then they follow.
  • Keep objects small. A change is recorded with the object before it, the object after it and the input, so an object of 1 MB needs about 2 MB of MongoDB’s 16 MB document limit.

What an operator sees when events are held back is in Operating.

Search
Search the documentation