VII Patterns

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 500 for “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 wrongSay it withThe caller getsBehind a receiver or a task
The request has the wrong shape, or asks for what a caller may never dothe input schema, the route400, 403, 404the message is set aside
The stored object would be invalid, whoever wrote itthe entity schema, a guardan exception, 500tried again, then set aside
The current state does not allow this requesta returned error422 and a codethe work ends, silently
There is nothing to do: it is already soa success without a write200the work ends
Something is broken or awayan exception — thrown for you500tried 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 caseThe answer
reason is not a string400, with a sentence saying so; cancel did not run
another customer’s token403; cancel did not run
no such order404; cancel did not run
the order is shipped422 with code: SHIPPED
the order is already cancelled200, the order as it is
the order is not shipped yet200, the order cancelled
the storage is away500

§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:id on the route, not an if in 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/catch that 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.
Search
Search the documentation