VII Patterns

Keep Data About a Customer without Creating It

A customer signs up. The shop keeps money for them, loyalty points, a subscription, and next year something nobody has thought of yet. In most systems signing up therefore writes a row in every one of those places: an empty wallet, zero points, no subscription. This article is about not doing that.

§What goes wrong

accounts is the customer — Sign Users Up and In creates it. The obvious design makes everything else when an account is made:

accounts.create ──> event accounts.created
                      ├──> billing.wallets      create an empty wallet
                      ├──> loyalty              create a record with 0 points
                      └──> billing.memberships  create "no membership"

Each arrow is a receiver and an operation whose only job is to write nothing in particular. And it does not hold:

  • Between sign-up and the receivers there is a customer without a wallet. The first page they open asks for a balance, and the answer is “not found”. Every reader of every one of these components has to handle a customer who is not there yet.
  • A receiver that fails leaves the customer half made, until the event is delivered again.
  • A component added next year has no records for last year’s customers. Somebody writes a script that walks every account.
  • accounts ends up knowing who depends on it, or everyone ends up listening to it. Either way a component that any application could use is now tied to this one.

All of this work is done to store the fact that nothing has happened.

§Say what “nothing yet” is, once

An associated entity has no id of its own: its id is the id of another entity. A wallet is identified by its customer’s id, and so are that customer’s points.

A component declares its entity associated, and says in blank what an object holds before anything is written to it:

# components/billing.wallets/manifest.toa.yaml
name: wallets
namespace: billing

entity:
  associated: true
  properties:
    balance: { type: integer, minimum: 0 }
  required: [balance]
  blank:
    balance: 0

operations:
  credit:
    concurrency: retry
    query: true
    input:
      type: object
      properties:
        amount: { type: integer, minimum: 1 }
      required: [amount]
// components/billing.wallets/operations/credit.ts
import type { CreditInput, State } from '../types/index.d.ts'

export async function transition (input: CreditInput, entry: State) {
  entry.balance += input.amount

  return { balance: entry.balance }
}
# components/loyalty/manifest.toa.yaml
name: loyalty

entity:
  associated: true
  properties:
    points: { type: integer, minimum: 0 }
    tier: { type: string, enum: [bronze, silver, gold] }
  required: [points, tier]
  blank:
    points: 0
    tier: bronze

Now any id names a wallet, and the customer’s id is the one to use. Nothing is created at sign-up, and nothing listens to accounts.

$ npm run restart

Ask for the wallet of a customer nobody has written anything about:

$ npx toa call billing.wallets.observe "{ query: { id: '8d1f0c2e5a7b4c3d9e6f1a2b3c4d5e6f' } }"
{
  id: '8d1f0c2e5a7b4c3d9e6f1a2b3c4d5e6f',
  VERSION: 0,
  DELETED: null,
  REGION: 0,
  balance: 0,
  CREATED: 1791372724647,
  UPDATED: 1791372724647
}

It is the blank, and VERSION: 0 says that nothing is stored. The same question to loyalty answers points: 0, tier: 'bronze'.

The first write stores it:

$ npx toa call billing.wallets.credit "{ query: { id: '8d1f0c2e5a7b4c3d9e6f1a2b3c4d5e6f' }, input: { amount: 5000 } }"
{ balance: 5000 }

credit received the blank as entry, like any other state, and its commit created the wallet at VERSION: 1. The operation has no branch for a wallet that is new: there is no such case.

billing.wallets here is the least a wallet can be, and its credit the smallest form of one; A Wallet and Its Ledger builds the whole one.

§What is gone

With records made at sign-upWith associated
a create operation in every componentnone
a receiver of accounts.created in every componentnone
“not found” handled by every readernever happens
a customer who exists in one place and not yet in anothernever happens
a script that fills in old customers for a new componentnone: they all have its blank
accounts tied to whatever is kept about a customeraccounts knows of nobody

The last row is why accounts and loyalty can stay components of no namespace, usable by any application: neither knows the other is there. They share a convention — the customer’s id — and nothing else.

§Over HTTP

A route with the id in its path reads and writes the same way. The customer’s id is the id of the signed-in identity, so auth:id lets each customer reach their own and nobody else’s (Restrict Who Can Call a Route):

# components/loyalty/manifest.toa.yaml
exposition:
  /:id:
    auth:id: id
    io:output: [points, tier]
    GET: observe

A customer who signed up a second ago gets 200 and {"points":0,"tier":"bronze"}, not 404.

§What a blank has to be

A valid object. Whatever required lists has a value in blank. A blank that does not fit the entity schema could not be stored by the first write either.

What is true of someone who has done nothing. No money, no points, the lowest tier. If there is no honest value of that kind — a delivery address, say — the property is not required and has no blank, and its absence is the answer.

The same for everyone. A blank is declared, not computed. A value that depends on the customer — a welcome bonus for those who came by invitation — is a write, made by whoever knows about the invitation.

§What it does not do

A list holds only what was written. enumerate on a wallet nobody credited finds nothing:

$ npx toa call billing.wallets.enumerate "{ query: { limit: 10 } }"
[]

So “every customer with an empty wallet” is not a question for billing.wallets. The list of customers is accounts.

The first write is a transition. An assignment describes a change without reading the object, so it has nothing to start from: the inherited assign on a wallet never written fails with StateNotFoundException. A transition is given the blank.

created means first written, not signed up. The inherited event of a new object fires when a wallet is first credited. Whoever needs to know that a customer signed up subscribes to accounts.

An id is not checked. Any 32 hexadecimal characters name a wallet, whether or not a customer has that id. That is the point, and it means the route decides who may write: auth:id above lets a customer write only under their own.

§When not to use it

When existing means something. An order that was never placed is not an order with blank properties: asking for it must answer “not found”, and creating it is an event the business cares about. The same goes for a voucher, a shipment, a payment.

The test: is “nothing about this yet” a normal state of the thing, with an honest value? For what is kept about something else — money, points, preferences, limits, counters, a copy of a subscription — it is. For the things themselves it is not.

§Rules

  • Data kept about a customer lives in a component of its own, identified by the customer’s id, with an associated entity.
  • Its blank says what is true before anything happened, and satisfies required.
  • Nothing creates it, nothing listens for sign-ups to prepare it, and no reader handles its absence.
  • Its first write is a transition.
  • The list of customers is asked of accounts, never reconstructed from what is kept about them.

The mechanics of associated and blank are in Describe State; who owns a fact, and who keeps a copy of it, is in Ask the Owner, or Keep a Copy.

Search
Search the documentation