Call, Event, or Task
When an order is approved, four things must happen: the customer’s wallet is debited, a receipt
is emailed, loyalty points are credited, and the sales report is updated. Four components are
involved, and store.orders has three ways to make another component do something: call it and
wait, announce an event, or hand over a task. Which one is the most frequent design decision in
an application, and one rule settles it.
If you need the answer to decide or to reply, make a call. If it is a consequence of a change you have stored, announce an event. If it is a command to one operation that nobody waits for, and there is no stored change to announce, hand over a task.
§What goes wrong with calls only
store.orders is the component Create a Component writes, and approve is its transition that lets a pending order through. billing.wallets keeps a
customer’s money under the customer’s id
(Describe State), and its debit takes an amount off the balance, or refuses with INSUFFICIENT_FUNDS (A Wallet and Its Ledger). notifications, loyalty and store.reports are the components that send mail, keep the points and keep the report. DISCARD answers a repeated request as a success
and writes nothing.
The first version most people write calls everything in a row:
// components/store.orders/operations/approve.ts — do not write it this way
import type { Context, State } from '../types/index.d.ts'
export async function transition (input: null, entry: State, context: Context) {
if (entry.status === 'approved') {
entry.DISCARD = true // asked again: it is approved already
return entry
}
if (entry.status !== 'pending') return new Error('NOT_PENDING')
const debit = await context.remote.billing.wallets.debit({
query: { id: entry.customer },
input: { amount: entry.total, ref: entry.id }
})
if (debit instanceof Error) return debit
await context.remote.notifications.receipt({
query: { id: entry.id },
input: { customer: entry.customer, total: entry.total }
})
await context.remote.loyalty.award({
query: { id: entry.customer },
input: { order: entry.id, points: Math.floor(entry.total / 100) }
})
await context.remote.store.reports.add({ input: { order: entry.id, total: entry.total } })
entry.status = 'approved'
return entry
} It works on a developer’s machine and fails in three ways once it is used:
- The customer waits for all of it. The reply is as slow as the mail provider is today.
- Any of the four can fail the approval. The mail provider is away,
receiptthrows,approvethrows — after the wallet was debited. The customer is charged for an order that stayspending. - A process that stops in the middle loses the rest. If it stops after the status is stored and before the last call, nobody ever updates the report, and nothing knows that it should.
Only the debit belongs in that list. The approval cannot be decided without knowing whether the money is there. The other three do not change the decision; they follow from it.
§The solution
client ── PUT /store/orders/:id/ ──> store.orders.approve
│
├─ call ──> billing.wallets.debit waits: the answer decides
│
└─ stores status: approved, replies
│
~store.orders.approved after the reply
├──> notifications.receipt
├──> loyalty.award
└──> store.reports.add The operation keeps the one call it needs:
// components/store.orders/operations/approve.ts
import type { Context, State } from '../types/index.d.ts'
export async function transition (input: null, entry: State, context: Context) {
if (entry.status === 'approved') {
entry.DISCARD = true // asked again: it is approved already
return entry
}
if (entry.status !== 'pending') return new Error('NOT_PENDING')
const debit = await context.remote.billing.wallets.debit({
query: { id: entry.customer },
input: { amount: entry.total, ref: entry.id }
})
if (debit instanceof Error) return debit
entry.status = 'approved'
return entry
} The component says which change is worth announcing:
// 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'
} And each of the others subscribes, in its own manifest:
# components/notifications/manifest.toa.yaml
receivers:
store.orders.approved: receipt # components/loyalty/manifest.toa.yaml
receivers:
store.orders.approved: award # components/store.reports/manifest.toa.yaml
receivers:
store.orders.approved: add store.orders does not mention the three subscribers, and a fifth consequence next month is a
new receiver somewhere else, with no change here. The syntax is in Call Operations and Select State, Announce a Change and React to an Event. The operations behind the three receivers are
written in React to an Event (receipt), Events Worth Subscribing To (award)
and Handle Repeated Calls (add).
§What each of the three gives
§A call
You get the answer, and you wait for it
const debit = await context.remote.billing.wallets.debit({ query: { id }, input: { amount, ref } }) - The caller has the reply — an output or a refusal — before its next line.
- The caller is as slow as the callee, and fails when the callee fails: an exception in
debitis thrown at theawait. - What the callee wrote is already there when the caller replies. A client that reads the wallet right after the approval sees the new balance.
Use it when the answer is an input to your decision or part of your reply.
§An event
It is not lost, and nobody waits
An event is published because a change was stored, and it is recorded together with that change. There is no moment at which the order is approved and the announcement is missing, even if the process dies on the next instruction. It is then delivered to every subscriber, and delivered again until the subscriber’s operation has handled it — a subscriber that is down receives it when it is back.
- The reply does not wait for any subscriber.
- A failing subscriber does not fail the operation that made the change, nor the other subscribers.
- The publisher does not know who subscribes.
- The subscribers act a moment later. Until then the application is eventually consistent: every part will agree, but not at the same instant.
Use it for everything that must happen because your state changed. The promise behind it is described in Transactional Outbox.
§A task
A call nobody waits for
The shop has a “contact us” form. Nothing is stored about a message; it is emailed to the staff. There is no change to announce, and yet the customer should not wait for the mail provider:
// components/support.contact/operations/contact.ts
import type { ContactInput, Context } from '../types/index.d.ts'
export async function effect (input: ContactInput, context: Context) {
await context.remote.notifications.send({
input: { to: context.configuration.inbox, text: input.text },
task: true
})
} support.contact is a component with this one operation, an effect, and a setting, inbox:
# components/support.contact/manifest.toa.yaml
namespace: support
name: contact
operations:
contact:
input:
type: object
properties:
text: { type: string, maxLength: 2048 }
required: [text]
configuration:
schema:
properties:
inbox: { type: string }
required: [inbox] # context.toa.yaml — added to `configuration`
configuration:
support.contact:
inbox: staff@store.example It has no entity: it keeps nothing
(Describe State). notifications.send is the
effect that emails a text to an address
(Call an External Service).
The call answers null once the task is stored, and send runs later, when notifications is free to take it — also if it was down at that moment. A task is
addressed to one operation the caller names, where an event is addressed to nobody in
particular. See Do Work in the Background.
Use it for a command whose reply nobody needs, when there is no stored change it follows from.
§Choosing
| The situation | Choose | Because |
|---|---|---|
| The operation cannot decide without the answer: is there enough money, is the item in stock, is the signature valid | call | the answer is an input to the decision |
| The reply to the client contains what another component computes: a price, a quote | call | the answer is part of the reply |
| The client’s very next request must already see the result | call | an event is handled a moment later, and the next read may come first |
| Two things must happen in a given order | call, awaited, one after another | events and tasks arrive in no promised order |
| Something must happen because an order was approved: a receipt, points, a report line | event | recorded with the change, delivered until handled, does not hold up the reply |
| Several components care about the same change, or more will later | event | the publisher does not list them |
| Another component keeps a copy of your data | event | the copy follows every change, whoever made it |
| The slow part of your own operation can be done after the reply | event received by the same component | see Reply Before the Work Is Done |
| A command to one known operation, nobody needs its reply, nothing was stored: send this email, write this log line | task | kept and retried like an event, addressed like a call |
| Long work in another component that reports back later | task, and the result comes back as state or a call | see A Process Across Components |
| An outside system is waiting, and only it can try again later | call, before answering it | see below |
Two questions give the same answers:
- Do I need the answer now? Yes — call.
- Did I just store a change this follows from? Yes — event. No — task.
§The same work on two paths
The choice belongs to the path, not to the operation. A customer tops up the wallet at a
payment provider, and billing.topups.record keeps one record per paid checkout. It is reached in
two ways.
The provider’s notification. The provider posts a webhook — an HTTP request it sends to the
application — when a checkout is paid. payments.webhooks stores the notification and answers at
once; the record follows from the stored change:
# components/billing.topups/manifest.toa.yaml
receivers:
payments.webhooks.checkout: record Nothing in the answer to the provider depends on the record, and nothing is lost if billing.topups is down at that moment: the event is delivered until record has handled it.
The customer’s return from the checkout page. The browser comes back with the id of the
checkout. billing.topups.confirm reads the checkout from the provider and makes the record by a
call, because its reply to the page is the recorded top-up:
// components/billing.topups/operations/confirm.ts
return await context.local.record(request(checkout)) One operation, a receiver on one path and a call on the other. What differs is who is waiting and who is able to try again. Both paths are assembled in Receiving Webhooks and Taking a Payment, End to End.
A notification that cannot be handled without asking the provider is the opposite case. The shop
also sells a membership, and when one changes, billing.memberships.signal reads it from the
provider and stores a copy. If the answer to a webhook is not a success, the provider sends the
same notification again later, and keeps trying for days. So the operation that accepts the
webhook has the membership read before it answers, by a call, and passes a refusal on:
// components/payments.webhooks/operations/ingest.ts
const signalled = await context.remote.billing.memberships.signal({
input: { membership: notification.data.id }
})
// the provider could not be read: refuse, and the provider will send this again
if (signalled instanceof Error && signalled.code === 'UNAVAILABLE') return signalled Here the answer decides the reply. Had the webhook been accepted first and the read left to an event, a read that could not be done would have nobody to ask again (Mirroring a Provider’s Object).
§What can still go wrong
The same event or task arrives twice. Delivery is at least once: retried until handled, so
occasionally handled twice. loyalty.award that adds ten points adds twenty. Every
operation behind a receiver or a task is written to tolerate that: The Same Message Twice.
The subscriber has not acted yet. A scenario that approves an order and reads the loyalty points in the next line finds none. That is the price of not waiting. A client is told when the consequence lands (Push Changes to the Browser) or reads again; if it truly cannot proceed without the result, the result belongs in the reply, and that is a call.
Events arrive in another order than they were published. cancelled may overtake approved. See Messages Out of Order.
The callee of a call is down. The call throws, and so does the caller; whoever called it — a client, or the runtime delivering an event — tries again. Do not catch the exception to carry on without the answer you said you needed.
A refusal with nobody to hear it. An error returned by an operation that a receiver or a task called ends the work silently. If somebody must learn of it, the operation writes it into state; see Saying No.
A task says nothing about how it ended. Its call answers null, so if (reply instanceof Error) after a task: true call is never true. The outcome comes back as
state or as an event.
A task made inside a transition is not tied to the transition’s change. The task is stored when it is handed over, and the change only when the function returns. If the change is then not stored, the task runs anyway; if the transition is run again, the task is handed over again. An event has neither problem, which is why a consequence of a change is an event. Hand over tasks from effects, or where running the task without the change does no harm.
§Rules of thumb
- Need the answer to decide or to reply: call.
- It follows from a change you stored: event.
- A command to one operation, no reply needed, no change to announce: task.
- The next request must see it: call, never an event.
- Count the awaited calls in an operation that serves a client. Each one that does not feed the decision or the reply is a candidate for an event.
- A component that announces does not know its subscribers; a component that calls depends on its callee. Prefer the dependency that points at the component that changes less often.
- When an outside sender is the only one able to retry, do the part that can fail before answering it.