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.
accountsends 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-up | With associated |
|---|---|
a create operation in every component | none |
a receiver of accounts.created in every component | none |
| “not found” handled by every reader | never happens |
| a customer who exists in one place and not yet in another | never happens |
| a script that fills in old customers for a new component | none: they all have its blank |
accounts tied to whatever is kept about a customer | accounts 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
associatedentity. - Its
blanksays what is true before anything happened, and satisfiesrequired. - 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.