Events Worth Subscribing To
The shop credits loyalty points for an approved order and returns them when the order is
refunded. Orders are kept by store.orders, the component Create a Component writes. loyalty could subscribe to store.orders.updated, an event every component
publishes without declaring anything, and work out what happened by looking at the order.
It goes wrong quickly:
- Points are credited on every change of an approved order.
updatedsays “something changed”. A note added to an approved order is an update of an order whose status isapproved. - The subscriber cannot tell why a number changed. The total went from 4200 to 3000: a refund, or a corrected typo? The two states do not say.
- The subscriber depends on everything the order holds. The payload of
updatedis the whole order. Rename a property and a component nobody remembered breaks; add an internal one and it travels to every subscriber. - A new operation forgets the rule. If points were instead credited by a call from
approve, the “approve by staff” operation written next year would not make it.
An event is a contract the publisher offers. Four devices make it one worth subscribing to. Two are the publisher’s — the condition and the payload, with trailers feeding both — and two are the subscriber’s: the receiver’s own condition and request.
§Announce a fact, when it becomes true
An event describes a change of state, never an operation. Compare the state before the change with the state after it, so that the event is published when the fact becomes true:
// 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'
} origin is the order before the change and state the order after it. The condition is checked
after every stored change, whichever operation made it, so no operation can forget to “send” the
event, and none sends it twice.
Three shapes cover most events:
// became true: the order crossed into a status
event.origin?.status !== 'approved' && event.state.status === 'approved'
// was created, and only if it is of interest
event.origin === null && event.state.total > 0
// something the subscribers hold has changed
PUBLIC.some((key) => event.origin?.[key] !== event.state[key]) The last one is for subscribers that keep a copy of a few properties: with const PUBLIC = ['status', 'total'] they are not woken by a change of anything else.
§Say what the two states cannot
A refund lowers the order’s total. The order does not keep a list of refunds, so afterwards
nothing in it says that this change was a refund, of how much, or why. The operation knows; it
leaves a trailer, a note that travels with the change and is not stored in the order. refund is one more operation of store.orders:
# components/store.orders/manifest.toa.yaml
operations:
refund:
concurrency: retry
query: true
input:
type: object
properties:
amount: { type: integer, minimum: 1 }
reason: { type: string }
required: [amount, reason]
errors: [NOT_APPROVED, EXCEEDS_TOTAL] // components/store.orders/operations/refund.ts
import type { RefundInput, State } from '../types/index.d.ts'
export async function transition (input: RefundInput, entry: State) {
if (entry.status !== 'approved') return new Error('NOT_APPROVED')
if (input.amount > entry.total) return new Error('EXCEEDS_TOTAL')
entry.total -= input.amount
entry.TRAILERS.refund = { amount: input.amount, reason: input.reason }
return entry
} The trailer is the event:
// components/store.orders/events/refunded.ts
import type { Event } from '@toa.io/core/types'
import type { Entity } from '../types/index.d.ts'
type Refund = { amount: number, reason: string }
export function condition (event: Event<Entity, { refund?: Refund }>) {
return event.trailers?.refund !== undefined
}
export function payload (event: Event<Entity> & { trailers: { refund: Refund } }) {
return {
order: event.state.id,
customer: event.state.customer,
amount: event.trailers.refund.amount,
reason: event.trailers.refund.reason,
total: event.state.total,
version: event.state.VERSION
}
} No trailer, no event: a total corrected through another operation announces no refund.
§Shape the payload
Without a payload function an event carries the whole object. That is convenient on the first
day and a liability afterwards: every property is promised to every subscriber, including the
ones added later for internal use.
The payload above is the contract of refunded:
- an id to address by —
order, andcustomerbecause subscribers keep things per customer; - what happened —
amount,reason; - what is true now —
total, so that a subscriber need not ask; version— the order’sVERSIONafter this change, by which a subscriber tells a repeat and an older event from a newer one (Messages Out of Order).
Carry the id of whatever the subscriber will look up. An event about one line of an order that carries only the line’s id sends every subscriber back to ask which order it belongs to.
§Let the subscriber translate
The publisher speaks its own language: order, amount. The subscriber has its own operations
with their own inputs. The receiver’s file is where one is turned into the other, and where what
is of no interest is dropped:
# components/loyalty/manifest.toa.yaml
name: loyalty
entity:
associated: true # one object per customer, under the customer's id
properties:
points: { type: integer, minimum: 0 }
orders:
type: object
additionalProperties: { type: integer }
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
store.orders.refunded: award // 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 // small orders earn nothing
}
export function request (order: Order) {
return {
query: { id: order.customer },
input: { order: order.id, points: Math.floor(order.total / 100) }
}
} // components/loyalty/receivers/store.orders.refunded.ts
export function request (refund: { customer: string, order: string, total: number }) {
return {
query: { id: refund.customer },
input: { order: refund.order, points: Math.floor(refund.total / 100) }
}
} // 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
} Both events end in the same statement — “this order is now worth this many points” — and award sets it. Neither store.orders nor its events know that points exist. The entity is associated, so a customer’s
points need no creation, and Order is the type of an order, imported from the other component’s types.
Try it on a pending order of 4200; the ids here are examples, the second one the order’s customer:
$ npm run restart
$ npx toa call store.orders.approve "{ query: { id: '5b1ad5d8a4c54e1dbb2a4a0b6e1f0c11' } }"
$ npx toa call store.orders.refund "{ query: { id: '5b1ad5d8a4c54e1dbb2a4a0b6e1f0c11' }, input: { amount: 1200, reason: 'damaged' } }"
$ npx toa call loyalty.observe "{ query: { id: '8d1f0c2e5a7b4c3d9e6f1a2b3c4d5e6f' } }" { id: '8d1f0c2e5a7b4c3d9e6f1a2b3c4d5e6f', points: 30, orders: { '5b1ad5d8a4c54e1dbb2a4a0b6e1f0c11': 30 }, VERSION: 2 } The syntax of all four is in Announce a Change and React to an Event.
§Why a declared event and not updated
Subscribe to an event that names the fact you react to; if there is none, declare it in the publisher rather than work it out in the subscriber.
A subscriber that derives “approved” from updated has copied the publisher’s rule into its own
code, and so has the next subscriber. When the rule changes — an order may also be approved in
part — every copy is wrong separately. A declared event keeps the rule with the component that
owns the state, and the subscribers keep only their reaction.
The inherited created, updated, deleted and sync remain right where the reaction really
is “whatever changed”: a copy that follows its source, a search index, a client’s stream.
§What can still go wrong
The event is delivered twice. A condition that compares origin with state makes the
publisher announce once; it does not stop the same announcement from arriving twice. award tolerates it because it sets the points of an order and does not add to them. This is the
subscriber’s duty for every event: The Same Message Twice.
The condition never becomes true for a new object. event.origin.status !== 'approved' throws for an object that was just created, and a condition written as event.origin !== null && … skips an order that is created already approved. Write event.origin?.status, and think about creation separately.
A condition or a payload throws. Every event of that change is held back until a corrected version is deployed. Keep them to comparisons of the argument: no calls, no clock.
The payload must change. Adding a property is safe. Removing or renaming one breaks the receivers that read it, so do it in two releases: subscribers stop using it, then the publisher drops it. See Release a New Version.
Two components subscribe to each other. store.orders reacts to loyalty.updated while loyalty reacts to store.orders.updated, and a change goes round until it is
refused as a loop. Narrow events are the cure: a write caused by a receiver should not publish
the event that triggers it. See Loops and Read-Only Calls.
A secret travels in a payload. An order that holds a delivery code publishes it to every
subscriber of updated. A property that must not leave the component is a reason to declare
events with payloads and to subscribe to those.
A consumer outside the application. An event that another system reads has no receiver in the application, and is published in a deployment only when the Context lists it; see Announce a Change. Its payload is a public interface: version it as carefully as an HTTP route.
§Rules of thumb
- A condition describes a change of state, compared between
originandstate. It never mentions an operation. - When the meaning is not in the two states, the operation leaves a trailer, and the condition reads it.
- A payload is a contract: an id to address by, what happened, what is true now, and the version.
- The receiver’s
conditiondrops what its component does not care about; itsrequesttranslates the rest into the component’s own input. - A subscriber never asks the publisher to change for its sake, and the publisher never lists its subscribers.
- Whatever the event, the operation behind the receiver tolerates a repeat.