Ask the Owner, or Keep a Copy
An order needs the price of each product, and prices belong to store.catalog. The account page
shows the customer’s loyalty tier, and tiers belong to loyalty, the component that keeps the
points (Events Worth Subscribing To). A promotion
voucher works a hundred times, and somebody has to count.
Every fact in the application has one owner: the component whose operations write it. No other component can read its storage, so “just join the two tables” is not available. Two shortcuts suggest themselves, and both go wrong:
- Copy everything you might need.
store.orderskeeps its own list of prices. Now a price is changed in two places, the discount rule is written twice, and the copies drift apart. - Read, decide, write back.
store.ordersreads how many uses a voucher has left, creates the order, then tells the voucher to count one. Two orders placed at the same moment both read “one left”.
There are three sound ways to use a fact you do not own, and one way to keep a counter.
§Ask the owner
When an operation needs a fact to make its decision, it calls the owner and waits for the answer.
The owner keeps the data and the rule. store.catalog holds a price per product, and quote is an observation:
# components/store.catalog/manifest.toa.yaml
namespace: store
name: catalog
entity:
associated: true # any product code names an object; one without a price is not for sale
properties:
id: { type: string, pattern: '^[A-Z]{3}-[0-9]{4}$' }
price: { type: integer, minimum: 0 }
operations:
quote:
query: true
input:
type: object
properties:
quantity: { type: integer, minimum: 1 }
required: [quantity]
errors: [UNKNOWN_PRODUCT] // components/store.catalog/operations/quote.ts
import type { Entity, QuoteInput } from '../types/index.d.ts'
export async function observation (input: QuoteInput, entry: Entity) {
if (entry.price === undefined) return new Error('UNKNOWN_PRODUCT')
const discount = input.quantity >= 10 ? 0.9 : 1
return { amount: Math.round(entry.price * input.quantity * discount) }
} create of store.orders, which Add Configuration writes
as a transition, stops taking total from the client and asks the catalog. Its declaration and
its file are replaced:
# components/store.orders/manifest.toa.yaml
operations:
create:
query: false
errors: [UNKNOWN_PRODUCT]
input:
type: object
properties:
customer: { type: string }
items:
type: array
items:
type: object
properties:
product: { type: string }
quantity: { type: integer, minimum: 1 }
required: [product, quantity]
required: [customer, items] // components/store.orders/operations/create.ts
import type { Context, CreateInput, State } from '../types/index.d.ts'
export async function transition (input: CreateInput, entry: State, context: Context) {
let total = 0
for (const item of input.items) {
const quote = await context.remote.store.catalog.quote({
query: { id: item.product },
input: { quantity: item.quantity }
})
if (quote instanceof Error) return quote
total += quote.amount
}
entry.customer = input.customer
entry.items = input.items
entry.total = total
return { id: entry.id, total }
} create declares UNKNOWN_PRODUCT in its own errors, so the refusal of the catalog becomes
its refusal (Refuse a Request).
With TEA-0042 priced at 450 in the catalog:
$ npm run restart
$ npx toa call store.orders.create "{ input: { customer: 'c1', items: [{ product: 'TEA-0042', quantity: 10 }] } }" { id: '3f7a9d2c1b4e4a6f8c5d0e1b2a3c4d5e', total: 4050 } The ten-for-less rule lives in one file. When it changes, store.orders is not touched, and no
order was ever priced by an older copy of the rule.
§Keep the answer you decided on
total is a copy too, but a deliberate one: the price as it was when the customer ordered.
Tomorrow’s price change must not reach yesterday’s order, so the order freezes the answer and
never asks again. Store the answer, not the inputs it was computed from: the order keeps total,
not a price list.
§Keep a copy that follows the owner
The account page is read on every visit and shows the name and the tier — bronze, silver or gold, which loyalty works out from points and keeps in a tier property. Asking loyalty on every read would make every account page depend on it, and a browser cannot be
pushed a change of something the account does not hold.
Here the reader keeps a small copy, and the owner’s event keeps it current. The owner announces only the change the copy is made of:
// components/loyalty/events/ranked.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?.tier !== event.state.tier
}
export function payload (event: Event<Entity>) {
return { customer: event.state.id, tier: event.state.tier, version: event.state.VERSION }
} The reader receives it into a property of its own entity. accounts is the component with one object per customer and the customer’s name (Sign Users Up and In); it gets tier, ranked, an operation, a
receiver and a realtime section:
# components/accounts/manifest.toa.yaml
entity:
associated: true
properties:
name: { type: string }
tier: { type: string, enum: [bronze, silver, gold] }
ranked: { type: integer } # the version of the loyalty record the tier was copied from
blank:
tier: bronze
ranked: 0
operations:
rank:
concurrency: retry
query: true
input:
type: object
properties:
tier: { type: string }
version: { type: integer }
required: [tier, version]
errors: [OBSOLETE]
receivers:
loyalty.ranked: rank
realtime:
sync:
key: id
expose: [id, name, tier, VERSION] // components/accounts/receivers/loyalty.ranked.ts
export function request (ranked: { customer: string, tier: string, version: number }) {
return { query: { id: ranked.customer }, input: { tier: ranked.tier, version: ranked.version } }
} // components/accounts/operations/rank.ts
import type { RankInput, State } from '../types/index.d.ts'
export async function transition (input: RankInput, entry: State) {
if (input.version < entry.ranked) return new Error('OBSOLETE')
entry.tier = input.tier
entry.ranked = input.version
} loyalty accounts
points change, tier becomes gold
~ loyalty.ranked ───────────> rank: tier = gold ──sync──> the customer's browser One read of the account now answers everything the page shows, accounts works while loyalty is being deployed, and the browser learns of a new tier from the account’s own sync.
The price of a copy is that it is eventually consistent: for a moment after the owner changed, the copy still says the old thing. A customer who has just become gold may see silver for a fraction of a second. Decide whether the reader can live with that; an account page can. A decision that must not be made on an old value — “is there enough money” — is asked of the owner.
§Which one
Ask the owner for a fact you need once, inside an operation that is deciding something. Keep a copy only of what clients read all the time, what you have to select by, or what you must freeze.
| The fact is | Do |
|---|---|
| an input to this operation’s decision or reply | a call to the owner, awaited |
| something that must stay as it was at the decision | store the answer in your own state |
| read by clients on every page, or pushed to them | a copy written by a receiver |
needed in your own criteria — “orders of gold customers” | a copy written by a receiver |
| a rule, a formula, a limit | leave it with the owner; ask for the result |
Copy what is served, not what it was derived from: the account keeps tier, not the points and
the thresholds. Otherwise the rule exists twice.
§An object that needs no creation
“Create the wallet when the customer registers” is a second write after the first, and a process
that stops between them leaves a customer without a wallet. An entity that exists whenever its
owner exists is declared associated instead: its id is
the customer’s id, reading one nobody wrote answers the blank, and the first write creates it.
The customer’s points in loyalty are kept that way, and so is the wallet, billing.wallets: Keep Data About a Customer without Creating It.
The other side of it: under associated, every id “exists”. Where an untouched object is not a
valid one, the operation says so, as quote does for a product without a price.
§A limit lives with its counter
A voucher can be used a hundred times. The count belongs to store.vouchers, and so does the
check. Checking and counting are one operation of the owner, not a question followed by an
update:
# components/store.vouchers/manifest.toa.yaml
namespace: store
name: vouchers
entity:
associated: true
properties:
id: { type: string, pattern: '^[A-Z0-9]{4,16}$' }
discount: { type: integer, minimum: 0 }
left: { type: integer, minimum: 0 }
blank:
discount: 0
left: 0
operations:
redeem:
concurrency: retry
once: true
query: true
errors: [EXHAUSTED] // components/store.vouchers/operations/redeem.ts
import type { State } from '../types/index.d.ts'
export async function transition (input: null, entry: State) {
if (entry.left < 1) return new Error('EXHAUSTED')
entry.left -= 1
return { discount: entry.discount }
} create takes one more property in its input, voucher: { type: string }, and declares EXHAUSTED among its errors:
// components/store.orders/operations/create.ts, after the total is known
if (input.voucher !== undefined) {
const voucher = await context.remote.store.vouchers.redeem({ query: { id: input.voucher } })
if (voucher instanceof Error) return voucher
entry.total = Math.max(0, total - voucher.discount)
} Two orders that reach for the last use meet inside redeem: one is stored, the other is run
again on the state the first left and answers EXHAUSTED (Two Writers at Once). Take the unit before doing what it pays for, not
after: the refusal then arrives while it can still stop the order.
Counting is adding, so redeem declares once: if create runs twice for one request, the voucher counts once.
§What can still go wrong
The owner is down. A call to it throws, and the caller fails with it. Let the exception through: a request that cannot be priced should fail, and an operation reached by an event or a task is tried again by itself. A copy has no such moment; that is part of what it buys.
The same event arrives twice, or two arrive in the wrong order. rank sets a value, so a
repeat changes nothing, and the version it keeps refuses a tier older than the one it holds: The Same Message Twice, Messages Out of Order.
The order fails after the voucher was counted. If create throws after redeem answered, a
use is gone and no order has it. For a voucher that may be acceptable. Where it is not — money,
stock — take the unit as a hold that is confirmed or given back: Reservations.
The copy outlives its source. If the owner’s object is deleted, nothing removes the copy
unless a receiver of the owner’s deleted event does. Decide it when you add the copy.
A rule changes at the owner. Asked facts follow at once. Copies follow when the owner next announces a change, and frozen answers never do — which is what each was chosen for.
§Checklist
- One component owns each fact; others ask it or copy it, and never write it.
- A decision is made by a call to the owner, and the rule stays with the owner.
- A copy is written by a receiver, holds only what is served, and carries the owner’s version.
- Say out loud that a copy can lag, and check that its readers can live with it.
- An entity that exists whenever its owner does is
associatedand has ablank. - A limit is checked and counted in one transition of the component that holds the counter.