II Business Logic

React to an Event

After an order is approved, the customer gets a receipt. The orders component, store.orders, only announces the approval as store.orders.approved; notifications, a component of no namespace, reacts to it. A receiver turns an event into a call to one of the component’s own operations.

§Binding an event to an operation

# components/notifications/manifest.toa.yaml
name: notifications

entity:
  associated: true
  properties:
    kind: { type: string, enum: [receipt, cancellation] }
    customer: { type: string }
    total: { type: integer }

operations:
  receipt:
    concurrency: retry
    query: true
    input:
      type: object
      properties:
        customer: { type: string }
        total: { type: integer }
      required: [customer, total]

receivers:
  store.orders.approved: receipt

The event is named <namespace>.<component>.<event>, and <component>.<event> where the component that publishes it has no namespace. A component has at most one receiver per event.

// components/notifications/receivers/store.orders.approved.ts
import type { Entity as Order } from '@components/store.orders'

export function request (order: Order) {
  return {
    query: { id: order.id },
    input: { customer: order.customer, total: order.total }
  }
}
// components/notifications/operations/receipt.ts
import type { ReceiptInput, State } from '../types/index.d.ts'

export async function transition (input: ReceiptInput, entry: State) {
  entry.kind = 'receipt'
  entry.customer = input.customer
  entry.total = input.total
}

The file in receivers, named after the event, exports request: it is given the event’s payload and builds the request the operation is called with. Here a notification is kept under the order’s own id, so one order has one receipt however many times the event arrives. Order is the type generated from the entity of store.orders. Sending the email itself is in Call an External Service.

Restart the application, approve a pending order, and look:

$ npm run restart
$ npx toa call store.orders.approve "{ query: { id: '5b1ad5d8a4c54e1dbb2a4a0b6e1f0c11' } }"
$ npx toa call notifications.observe "{ query: { id: '5b1ad5d8a4c54e1dbb2a4a0b6e1f0c11' } }"
{
  id: '5b1ad5d8a4c54e1dbb2a4a0b6e1f0c11',
  kind: 'receipt',
  customer: '8d1f0c2e5a7b4c3d9e6f1a2b3c4d5e6f',
  total: 1500,
  VERSION: 1,
  // …
}

The reaction happens after approve has answered, so in a test wait for it rather than reading at once.

store.orders knows nothing of this, and nothing in it changes when a subscriber is added.

§Without a receiver file

With only the line in the manifest, the operation is called with the event’s payload as its input and no query. That fits an operation whose input is the payload as it is:

receivers:
  store.orders.approved: record

§Skipping events

Loyalty points are credited only for orders of 1000 or more. loyalty keeps a customer’s points under the customer’s id, so its entity is associated and nothing creates it. Create components/loyalty:

# components/loyalty/manifest.toa.yaml
name: loyalty

entity:
  associated: true
  properties:
    points: { type: integer, minimum: 0 }
    orders:
      type: object
      additionalProperties: { type: integer }
  required: [points]
  blank:
    points: 0
    orders: {}

operations:
  award:
    concurrency: retry
    query: true
    input:
      type: object
      properties:
        order: { type: string }
        points: { type: integer }
      required: [order, points]

receivers:
  store.orders.approved: award
// components/loyalty/operations/award.ts
import type { AwardInput, State } from '../types/index.d.ts'

export async function transition (input: AwardInput, entry: State) {
  const before = entry.orders[input.order] ?? 0

  entry.orders[input.order] = input.points
  entry.points = Math.max(0, entry.points + input.points - before)

  return entry
}

award keeps what each order is worth and moves the total by the difference, so an event that arrives twice adds nothing. In the receiver’s file, export condition to receive only some of the events:

// components/loyalty/receivers/store.orders.approved.ts
import type { Entity as Order } from '@components/store.orders'

export function condition (order: Order) {
  return order.total >= 1000
}

export function request (order: Order) {
  return { query: { id: order.customer }, input: { order: order.id, points: 10 } }
}

Where it returns false, the event is done with and the operation is not called. Restart the application, approve a pending order of 1000 or more, and read the points of its customer:

$ npm run restart
$ npx toa call store.orders.approve "{ query: { id: '0c9e3b1f6a2d4e8f9b7c5d3a1e2f4b6c' } }"
$ npx toa call loyalty.observe "{ query: { id: '8d1f0c2e5a7b4c3d9e6f1a2b3c4d5e6f' } }"
{
  id: '8d1f0c2e5a7b4c3d9e6f1a2b3c4d5e6f',
  points: 10,
  orders: { '0c9e3b1f6a2d4e8f9b7c5d3a1e2f4b6c': 10 },
  VERSION: 1,
  // …
}

§One operation for several events

The shop notifies about approvals and cancellations alike; store.orders.cancelled is the event of a cancellation. arguments are passed to request after the payload, so receivers can share an operation and differ by a constant:

# components/notifications/manifest.toa.yaml
receivers:
  store.orders.approved:
    operation: notify
    arguments: [receipt]
  store.orders.cancelled:
    operation: notify
    arguments: [cancellation]
// components/notifications/receivers/store.orders.approved.ts
import type { Entity as Order } from '@components/store.orders'

export function request (order: Order, kind: 'receipt' | 'cancellation') {
  return { query: { id: order.id }, input: { kind } }
}
// components/notifications/operations/notify.ts
import type { NotifyInput, State } from '../types/index.d.ts'

export async function transition (input: NotifyInput, entry: State) {
  entry.kind = input.kind
}

Each event still has its own file in receivers.

§Receiving your own events

A component may receive its own events. It is a way to continue work after a change is stored without making the caller wait:

# components/store.orders/manifest.toa.yaml
receivers:
  store.orders.created: reserve

§What the operation must expect

Nobody waits for the reply of a call a receiver makes. So:

  • A returned error ends it. The event is done with. If the refusal must be visible, the operation writes it down.
  • An exception has it tried again, after a growing pause, and set aside for an operator if it keeps failing (Operating). Never throw to refuse.
  • The event may arrive more than once, and out of order.

So the operation must leave the same state when given the same event twice:

// applied twice, adds twice
entry.points += input.points

// applied twice, ends the same
entry.status = input.status

Where that is not natural, see Handle Repeated Calls. The reasons are in the model: Delivery.

§Events from outside the application

The payment provider’s system publishes payment_settled to a broker of its own. Such an event has no component to be found by, so state the binding and where to consume it from:

# components/store.orders/manifest.toa.yaml
receivers:
  payment_settled:
    operation: settle
    binding: amqp
    source: payments

source names a broker declared in the Context; see Connectors. It cannot be context. The receiver’s file is named after the event as written: receivers/payment_settled.ts.

A name in the <namespace>.<component>.<event> form needs no source: it is <namespace>.<component>.

receivers:
  billing.invoices.paid:
    operation: settle
    binding: amqp

§All properties

PropertyMeaning
operationThe operation to call. A receiver written as a string is this alone.
argumentsValues passed to request after the payload.
bindingWhere the event is consumed from; stated for events from outside.
sourceThe broker of an outside event, as named in the Context.
bridgeWhat runs the receiver’s code; node by default.
Search
Search the documentation