Shipmind Labs

The order lifecycle belongs in a table, not in your endpoints

· 9 min read

In most order systems, the rules that govern an order live nowhere in particular. The rule that the warehouse may ship only a paid order with stock on hand gets written in the ship endpoint, then again in the admin action, again in the batch job that clears yesterday's backlog, and once more in the frontend that decides whether to render the button. That is four implementations of one sentence, drifting apart quietly, and the drift is usually discovered by a customer sitting in a state nobody expected.

The same shape, over and over#

We have built this object in several domains. A lending marketplace has moderation and issuance tiers, each with its own set of actors. A high-ticket asset marketplace moves a deal from listing through deposits and multi-party document signing to closing, with brokers and compliance both holding veto power at different moments. A dark-store delivery marketplace has role-specific apps for couriers, warehouse staff and store operations, which is another way of saying that the same order looks like a different set of available actions depending on who is holding the phone.

Underneath, they are the same shape. A small set of states, a set of triggers, a rule about who may fire which trigger and when, and paperwork about what actually happened. What differs is only the table of rows.

The if-chain version of this is not wrong on day one. One service, one endpoint, one if order.status != "paid": raise. It becomes wrong on the day a second caller appears, because the second caller is written by reading the first one, and reading code is a lossy way to copy a rule. It becomes expensive on the day someone from support asks a question that sounds trivial (can support cancel a paid order?) and the honest answer requires grepping views.

So we moved the rules into a data structure. The library we use for it is open: github.com/shipmindlabs/order-lifecycle. It is early development, and deliberately small, because the point is not the library. The point is that the lifecycle stops being control flow.

The lifecycle is a table of rows#

A lifecycle is a table of (from-state, trigger, to-state) rows, not a pile of method calls:

python
from order_lifecycle import (
    COURIER,
    CUSTOMER,
    SUPPORT,
    State,
    Transition,
    TransitionTable,
    Trigger,
    WAREHOUSE,
)

NEW = State("new")
PAID = State("paid")
SHIPPED = State("shipped")
DELIVERED = State("delivered", terminal=True)
CANCELLED = State("cancelled", terminal=True)

PAY = Trigger("pay")
SHIP = Trigger("ship")
DELIVER = Trigger("deliver")
CANCEL = Trigger("cancel")

TABLE = TransitionTable(
    (
        Transition(NEW, PAID, PAY, roles=CUSTOMER),
        Transition(PAID, SHIPPED, SHIP, roles=WAREHOUSE),
        Transition(SHIPPED, DELIVERED, DELIVER, roles=COURIER),
        Transition(NEW, CANCELLED, CANCEL),
        Transition(PAID, CANCELLED, CANCEL, roles={CUSTOMER, SUPPORT}),
    )
)

str(TABLE.find(NEW, PAY))                 # 'new --pay--> paid [customer]'
TABLE.available(PAID, role=WAREHOUSE)     # -> (paid --ship--> shipped [warehouse],)

Every type is a frozen dataclass, so the table is hashable, comparable, and safe to share as a module-level constant. That matters more than it sounds. A lifecycle you can hold in a variable is a lifecycle you can pass to a test, diff between two versions, and print for a person who does not read Python.

Cancellation is not a special mechanism here, it is two more rows. A cancellation from NEW is open to anyone, and a cancellation from PAID names the customer and support explicitly. The interesting policy question, who is allowed to cancel after money has moved, is answered in one visible place instead of being implied by the absence of a check.

A broken table fails at import#

The table validates itself when it is constructed, which in practice means at import time, which in practice means in CI. Two invariants are enforced. A terminal state may not be the source of any transition:

python
def __post_init__(self) -> None:
    if self.source.terminal:
        raise ValueError(
            f"state {self.source.name!r} is terminal; no transition may leave it"
        )

And a (source, trigger) pair may appear only once:

python
for row in rows:
    key = (row.source, row.trigger)
    if key in index:
        raise ValueError(
            f"duplicate transition for state {row.source.name!r} "
            f"and trigger {row.trigger.name!r}"
        )
    index[key] = row

The second one is the quiet win. In an if-chain, two branches that both match are a real possibility, and which one wins probably depends on the order they were added in over three years. Here the ambiguity cannot be constructed. find(state, trigger) returns exactly one row or None, and a lifecycle that contradicts itself breaks the build rather than a customer's order.

Wrong actor and wrong moment are different answers#

Roles answer who. Conditions answer when, and they are named predicates over the order that carry the sentence they will use when they refuse:

python
from order_lifecycle import Condition, flag

PAYMENT_CONFIRMED = flag(
    "payment_confirmed",
    name="payment confirmed",
    requires="the payment must be confirmed",
)
IN_STOCK = Condition(
    "in stock",
    lambda order: order["units_available"] > 0,
    "every line item must be in stock",
)

ship = TABLE.find(PAID, SHIP)   # with conditions=(PAYMENT_CONFIRMED, IN_STOCK)
ready = {"payment_confirmed": True, "units_available": 3}

ship.allows(WAREHOUSE, ready)   # -> True
ship.allows(CUSTOMER, ready)    # -> False, wrong actor
ship.allows(WAREHOUSE, {"payment_confirmed": False, "units_available": 3})
# -> False, right actor, wrong moment

Keeping those two questions separate is what makes a refusal usable by an operator. "Not allowed" sends a support agent to an engineer. "You may ship this, but the payment is not confirmed yet" sends them to the payment page:

python
[str(result) for result in ship.unmet({"payment_confirmed": False, "units_available": 0})]
# -> ['payment confirmed: the payment must be confirmed',
#     'in stock: every line item must be in stock']

A guarded transition also refuses a caller that supplied no role at all, so ship.permits(None) is False. Anonymous internal callers do not get a free pass because someone forgot to thread the actor through.

The client stops re-deriving the rules#

Once the table is data, the frontend does not need a second implementation of it. One endpoint asks the table what this actor may do to this order right now:

python
TABLE.available(PAID, role=WAREHOUSE, context=order)

Conditions are only evaluated when a context is supplied, so the same call serves both the cheap question (what does this role ever do here) and the expensive one (what can it do to this specific order). A courier app renders the buttons it is given, and when the policy changes, it changes in the table and the app follows without a release.

The permission matrix becomes a query rather than an archaeology project:

python
TABLE.roles              # every role the table guards a transition with
TABLE.for_role(SUPPORT)  # every transition support may fire, guarded or open

for row in TABLE:
    actors = ", ".join(sorted(role.name for role in row.roles)) or "anyone"
    print(f"{row.source.name:10} {row.trigger.name:10} {row.target.name:10} {actors}")

We generate that listing into the documentation for compliance and support review. It is the same object the code executes, so it cannot be out of date.

Applying a trigger, and what it leaves behind#

Declaring the table is half of it. The machine applies a trigger against an order or refuses it with a reason, hooks declared on the row run around the transition, and every applied transition is appended to an immutable history of what changed, when, and by whom.

The history being produced by the machine, rather than logged by the caller, is the part we insist on. History written by call sites is history that is missing exactly where someone was in a hurry: the admin override, the backfill script, the incident fix. When the only way to change state is to apply a transition, and applying a transition appends a record, the audit trail cannot have holes of that kind. Hooks belong on the row for the same reason. A notification that must accompany a shipment is a property of the shipment transition, not of whichever endpoint happened to trigger it this time.

What it costs to run#

The table is a module-level constant, and constants outlive orders. Orders that are in flight when the table changes were created under the previous rules, so removing a row is a migration question, not an edit. We add rows freely and remove them deliberately, after checking what is currently sitting in the affected states.

Conditions must be cheap, pure reads over a context that is already loaded. available() evaluates every condition on every candidate row, so put a network call in a predicate and you have built an N+1 into your order screen. Anything requiring a remote check gets resolved before the call and passed in as a field on the context.

The machine is not concurrency control. Two shipments racing on the same order is a database problem, and it stays one: the state write is a compare-and-set on the order row, committed in the same transaction as the history append. Hooks that touch the outside world run against a state that has already committed, because a notification about a shipment that got rolled back is worse than a late notification.

And the history only grows. That is the intended trade, we think: storage is cheap, and reconstructing what happened to an order from application logs is not.

The close#

Nothing here is clever. The whole argument is that the rules of an order are a small, finite, boring data structure, and that the moment you express them as branches, you lose the ability to validate them, query them, print them, or guarantee they were followed. Every service, screen and script that touches the order then re-derives the same sentences slightly differently. Writing the table down once is not architecture, it is refusing to type the same rule four times.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com