VII Patterns

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.orders keeps 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.orders reads 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 isDo
an input to this operation’s decision or replya call to the owner, awaited
something that must stay as it was at the decisionstore the answer in your own state
read by clients on every page, or pushed to thema 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 limitleave 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 associated and has a blank.
  • A limit is checked and counted in one transition of the component that holds the counter.
Search
Search the documentation