Saying No
An order can be cancelled until it is shipped. Five different things can stand between a “cancel” request and a cancelled order: the request is malformed; the order is someone else’s; the order was already shipped; the order is already cancelled; the database is away. Each deserves a different answer, and an application that gives them all the same one — an exception, usually — is hard to use and hard to operate:
- the client gets
500for “this order is already shipped” and cannot tell it from an outage; - the alerting fires for a customer’s mistake;
- an event whose handling “refused” by throwing is delivered again and again, as though waiting would help;
- a rule checked in one operation is missing in the one written next year.
§The five ways
| What is wrong | Say it with | The caller gets | Behind a receiver or a task |
|---|---|---|---|
| The request has the wrong shape, or asks for what a caller may never do | the input schema, the route | 400, 403, 404 | the message is set aside |
| The stored object would be invalid, whoever wrote it | the entity schema, a guard | an exception, 500 | tried again, then set aside |
| The current state does not allow this request | a returned error | 422 and a code | the work ends, silently |
| There is nothing to do: it is already so | a success without a write | 200 | the work ends |
| Something is broken or away | an exception — thrown for you | 500 | tried again, then set aside |
All five in the shop’s cancel. The manifest of store.orders, which Create a Component writes, gets two more statuses, the operation and
a route of its own, beside what is there:
# components/store.orders/manifest.toa.yaml
entity:
properties:
customer: { type: string }
items: { type: array }
total: { type: integer, minimum: 0 }
status: { type: string, enum: [pending, approved, picked, shipped, cancelled] }
required: [customer, items, total, status]
blank:
status: pending
operations:
cancel:
concurrency: retry
query: true
input:
type: object
properties:
reason: { type: string, maxLength: 256 }
required: [reason]
additionalProperties: false
errors: [SHIPPED]
exposition:
/:customer/:id/cancellation:
auth:id: customer
io:output: [id, status]
PUT: cancel // components/store.orders/operations/cancel.ts
import type { CancelInput, State } from '../types/index.d.ts'
export async function transition (input: CancelInput, entry: State) {
if (entry.status === 'cancelled') {
entry.DISCARD = true // already so: a success, and nothing is written
return entry
}
if (entry.status === 'shipped') return new Error('SHIPPED')
entry.status = 'cancelled'
entry.TRAILERS.reason = input.reason
return entry
} The reason is not kept in the order: it is left in TRAILERS, for the event that announces the cancellation.
Where an order may be cancelled only while it is pending, the one refusal is if (entry.status !== 'pending') return new Error('NOT_PENDING'); here an approved or a picked
order can be cancelled too, so the operation refuses a shipped one, with SHIPPED.
A cancelled order stays cancelled, whichever operation writes it:
// components/store.orders/guards/final.ts
import type { Entity } from '../types/index.d.ts'
export function guard (order: Entity, origin: Entity | null) {
return origin?.status !== 'cancelled' || order.status === 'cancelled'
} What a client sees, case by case:
PUT /store/orders/8d1f0c2e5a7b4c3d9e6f1a2b3c4d5e6f/5b1ad5d8a4c54e1dbb2a4a0b6e1f0c11/cancellation/ HTTP/1.1
content-type: application/yaml
reason: [1, 2] | The case | The answer |
|---|---|
reason is not a string | 400, with a sentence saying so; cancel did not run |
| another customer’s token | 403; cancel did not run |
| no such order | 404; cancel did not run |
| the order is shipped | 422 with code: SHIPPED |
| the order is already cancelled | 200, the order as it is |
| the order is not shipped yet | 200, the order cancelled |
| the storage is away | 500 |
§Which one, and why
§The shape of a request
The schema and the route
What can be decided without looking at the state is not written as code. The operation’s input schema refuses a wrong type or an unknown property; the route decides who may call and which
properties a client may send. A request refused there never reaches the operation, so the
operation does not check it again.
- A property that staff may set and a customer may not: one operation, two routes, the
customer’s with a narrower
io:input. - An order that is not the caller’s:
auth:idon the route, not anifin the operation.
§What must always be true
The schema and a guard
total is never negative; a cancelled order stays cancelled. These are not rules of cancel or of refund: they are rules of an order, whichever
operation writes it — including the inherited transit, and the operation a colleague adds next
year.
- One property’s shape or range goes into the entity schema:
minimum: 0,enum. - A rule over several properties, or over the state before and after, goes into a guard.
Neither is a way to answer anyone. A write they refuse is an exception, because it means some
operation tried to store nonsense — a bug. They are the net under the operations, and each
operation still returns its own error first. In the example, cancel never trips the guard; the
guard is there for the operation that forgets.
§What the state does not allow now
A returned error
The order is shipped, the balance is too low, the item is out of stock. Nothing is broken; the
request is understood and the answer is no. Return an Error with a code and declare the code:
if (entry.status === 'shipped') return new Error('SHIPPED') The caller can act on it: a client shows “this order can no longer be cancelled”, a calling
operation branches on reply.code. Nothing is stored, nothing is retried, nothing is alarmed.
See Refuse a Request.
A code is part of the operation’s contract. Name the situation — SHIPPED, INSUFFICIENT_FUNDS — not the check that failed, and keep the list short: a caller has to handle
each one.
§It is already so
A success without a write
A second “cancel” for a cancelled order is not a mistake. The client’s first reply was lost and it asked again; or the event was delivered twice. Refusing it tells a caller that did everything right that it failed.
Return what the first call would have returned, and set DISCARD so that nothing is stored and no event is
published a second time. Which is right — a success or an error — depends on what the caller
asked for: “make this order cancelled” is satisfied; “approve this order” for a cancelled order
is not.
§Something is broken
Let it throw
The storage is unreachable, a component you call does not answer, the code has a bug. You do not write anything for these: the exception is thrown for you, and it travels to whoever can try again — the client, or the runtime that delivers events and tasks.
- Do not throw to refuse. A thrown “not pending” is an outage in every dashboard and a retry storm behind every receiver.
- Do not catch to carry on. An operation that catches an exception from a call and returns a normal answer has replied with something it does not know to be true.
- Do not wrap what is retried anyway. A
try/catchthat logs and rethrows, or a loop that retries a call three times, repeats what already happens around the operation.
§Why keep them apart
An error is an answer the application was built to give; an exception is something the developers have to fix or wait out. Return the first, never throw it; let the second through, never return it.
Everything around the operation acts on that difference. A returned error becomes 422 and is
never retried. An exception becomes 500, is logged with its trace, and — where nobody is
waiting — has the message delivered again. Mix them up and each part does the wrong thing with
full confidence. The reasoning is in Replies.
§A refusal nobody hears
Behind a receiver or a task there is no caller. A returned error still ends the work — it is not tried again, which is right, since the answer would be the same — but nobody reads it:
# components/store.orders/manifest.toa.yaml
receivers:
fulfilment.shipments.refused: cancel If the order was already shipped when the courier’s refusal arrives, cancel returns SHIPPED and that is the end. Sometimes that is exactly what is wanted: the error doubles as “nothing to
do here”. When somebody must learn of it, the refusal is a fact, and facts are stored:
// components/store.orders/operations/decline.ts
import type { DeclineInput, State } from '../types/index.d.ts'
export async function transition (input: DeclineInput, entry: State) {
if (entry.status === 'shipped') {
entry.dispute = input.shipment // a shipped order that the courier refused: staff must look
} else {
entry.status = 'cancelled'
entry.TRAILERS.reason = 'REFUSED'
}
return entry
} The same holds for a failure of work done after the reply: write it where the client will read it (Reply Before the Work Is Done).
§Passing a refusal on
An operation that calls another receives its refusals and decides what they mean here. store.orders.approve debits the customer’s wallet, and billing.wallets.debit refuses with INSUFFICIENT_FUNDS when the balance is too low
(Refuse a Request):
// components/store.orders/operations/approve.ts
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 Returned as it is, the code must be among this operation’s own errors: approve declares INSUFFICIENT_FUNDS too. Translate when the callee’s word means nothing to your caller:
if (debit instanceof Error)
return new Error(debit.code === 'INSUFFICIENT_FUNDS' ? 'PAYMENT_DECLINED' : 'PAYMENT_FAILED') A component that talks to an outside service turns “the provider cannot be reached right now”
into an error of its own — UNAVAILABLE — when its callers can do something useful with it, such
as refusing a webhook so that it is sent again
(A Gateway to an External Service). Where no caller can, it lets the exception
through.
§Ask before the work, do not refuse after it
When a request arrives decides whether it may be refused.
billing.wallets.debit, called before an order is approved, refuses with INSUFFICIENT_FUNDS,
and the order is not approved. A delivery fee charged after the parcel was delivered cannot
usefully be refused: the parcel is not coming back. The operation that charges after the fact
takes what is there and records the rest, in an owed property of the wallet:
// components/billing.wallets/operations/charge.ts
import type { ChargeInput, State } from '../types/index.d.ts'
export async function transition (input: ChargeInput, entry: State) {
const taken = Math.min(input.amount, entry.balance)
entry.balance -= taken
entry.owed += input.amount - taken
return entry
} If the business cannot accept a debt, the check moves to before the work: set the money aside when the order is approved (Reservations).
§What can still go wrong
A rule kept only by a special operation. “An approved order cannot change its items” checked
in amend holds only for callers of amend; the inherited transit writes past it. Either
narrow the routes and operations so that amend is the only way in, or put the rule in a guard.
An undeclared code. Returning a code that is missing from errors fails the call with an
exception on a local run. Declare it; a test that reaches each refusal finds the missing ones.
An error where a failure happened. catch { return new Error('FAILED') } turns an outage
into an answer. The event that needed a retry is now finished, and the alert that would have
woken somebody stays silent.
A guard as the user interface. If clients regularly see the 500 of a guard, an operation
is missing its own check. Add the returned error; keep the guard.
“Not found” as a refusal. Whether another customer’s order exists is not the caller’s
business. A route that selects by owner answers 404 or 403 without the operation saying
anything; do not return NOT_YOURS.
§Rules of thumb
- Shape and permission: schema and route. The operation does not repeat them.
- True of the object whoever writes it: entity schema or guard.
- Not allowed now:
return new Error('CODE'), declared. - Already so: a success with
DISCARD. - Broken or away: nothing to write; let it throw, and do not catch it.
- Behind a receiver or a task a returned error is final and silent: store what somebody must see.
- Before the work, refuse; after the work, settle.