From Cart to Order: The State Machine the Hosts Ripped Out
· tech
#war-story#live-commerce#system-design
📑 Contents
- Two carts, one table
- The table a rebuild would add: the cart’s operation log
- Merged checkout: three layers, each answering to a reality
- An order’s “status”: five columns, zero state machines
- The life cycle of a price: check the fact before the commitment, freeze it after
- Reflections
- The state machine losing to the hosts is the healthiest surrender I’ve seen
- The three layers aren’t fastidiousness; three realities each need one
- This chapter is “boring” because the earlier chapters paid the debt
The last stop on the transaction spine: how an order travels from cart to order. Comments made it into the cart, identities hold the orders, stock pinned the invariant down — this chapter aggregates all of it into something that can be paid for, shipped and invoiced. The title isn’t a metaphor: there really was a state machine in this system, and the hosts ripped it out by their actions.
Two carts, one table
The overview said there are two carts: the live-stream one that reserves stock and the storefront one that doesn’t. The data model’s answer already showed its face in the identity chapter: one cart item table, with content type + object id (a generic foreign key) marking the source. Whether it reserves, whether it uses the live price or the storefront price, is decided by the source; quantity adjustment, checkout and clearing all run the same logic.
That makes the edges of quantity adjustment clean too. To increase a reserving order is just running the stock chapter’s conditional update again — you only get it if it’s there; to decrease is a release, returning the difference to the counts. An LWW amendment from a comment, a customer adjusting it themselves, support adjusting on their behalf — three paths, the same set of actions, only a different trigger.
The table a rebuild would add: the cart’s operation log
We just counted the cart’s writers: LWW amendments, the customer, support, plus the stock chapter’s round settlement and re-call reset — one table, five ways in. But the five leave badly asymmetric traces. Changes arriving by comment have full provenance (that msg → cart item chain: structured, joinable). Changes made by people weren’t unrecorded — we wrote operations into Django’s built-in log table by hand — the problem is that table is a catch-all for the entire system: cart adjustments, product edits and every miscellaneous operation share one table, whose columns are generic enough to mean only “who, touched which thing, some text”. Answering “how did this order end up like this?” means fishing text out of a junk drawer: finding it is luck, and when you do there’s no before-and-after quantity, no delta, nothing that lines up with a recomputation. Support hearing “why does my cart look like this?” could trace the comment half and only excavate the human half. The lesson is very concrete: “we have logs” and “we have logs you can query” are two different things — a generic log table reassures everyone when writing and amounts to nothing when reading.
In a rebuild I’d open a dedicated operation log table for cart items — not “start logging”, but moving the log out of the junk drawer and giving it structure: on every change, append a row inside the same transaction, with the trigger (which comment, the customer, which support agent, round settlement, a re-call), before-and-after quantities and the delta, in fixed columns you can join. Three points of care:
- It’s a change log, not a source of truth. The cart table remains the transactional fact and still guards against overselling inside a synchronous transaction — this is not turning the cart into event sourcing. Overselling is a hard invariant and the check has to happen before the write; moving “check then decrement” into an event store and deriving state through projections saves none of the hard serialisation decisions, it just changes their clothes.
- Appending in the same transaction costs almost nothing and buys three things: complaints can be reconstructed, support operations have an audit trail (the console chapter‘s rebuild list wanted that anyway), and when the hourly recomputation finds a difference there’s finally somewhere to investigate — a non-zero difference means some incremental path has a bug, and this table is the case file.
- It isn’t an invention, it’s giving the order the discipline the goods already had. The fulfilment system already had a picking log and could rebuild stock movements entirely from events (the shipping chapter covers it) — what the goods got, the order deserves. The day chasing payment, risk or analytics wants the cart’s change stream, this table becomes an outbox: an evolution, not a rewrite.
Merged checkout: three layers, each answering to a reality
Checkout had one brutal requirement supported from the start: merged checkout across rounds — buy things from different rounds and different hosts, pay once. That forced a three-layer structure:
Each layer’s reason to exist is more practical than “normalisation”:
- payment is the unit of paying. The customer doesn’t care about your rounds, they want to settle once — so the aggregating layer has to exist. Its state is aggregated from third-party payment facts (partial payments, multiple channels; the fun is saved for the next chapter).
- order is the unit of fulfilment, split by round. Personal-shopping goods arrive by round and ship by round, so the after-sales rhythm is naturally bounded by rounds; round-limited coupons are recorded at this layer too.
- order item is the unit of accounting. The amount freezes at the moment of sale — invoices, refunds and reconciliation all stand on a number that never moves again.
And the shape of the “cart to order” action itself already trails the next section: checkout doesn’t change a cart item’s status — payment inserts an order item, completed in the same transaction as the ledger’s “cart quantity becomes order quantity”. A state transition expressed as a new record, which naturally extends the provenance chain by one link: msg → cart item → order item, so any completed sale can be traced all the way back to the comment that started it.
An order’s “status”: five columns, zero state machines
The textbook teaches you to draw the order a pretty state machine: created → awaiting payment → paid → picking → shipped → complete. That isn’t what we had — an order’s “status” was five independent columns: payment status, invoice status, refund status, shipping status, support flag — each updated to follow a fact, with no transition rules at all.
The maths first, then the people. The maths: these five things evolve independently — payment completing doesn’t determine whether an invoice can be issued, and a refund can happen at any stage of shipping. Force them into one state machine and the state count is the Cartesian product of five dimensions, most of whose hundreds of combinations carry no business meaning while each still demands an answer to “who can transition to whom”. Orthogonal things should be stored orthogonally.
The people: that state machine wasn’t hypothetical — a colleague on the bidding system really did build transition rules, and then discovered that hosts change whatever they want. Every time a status change was blocked it read to them as friction rather than protection, and in the end the state machine was ripped out. I think that’s a healthy surrender, but it’s worth taking a sharper lesson: a state machine suits a process you control, not a reality you’re merely recording. There are two kinds of thing in a system — hard invariants (never oversell, the money has to add up), guarded to death by database-level constraints that nobody can route around; and soft status (the progress of human work), recorded but never enforced, because the realities of hosts, suppliers and support were never yours to govern. The shape we ended with happened to be right: overselling locked down, status left free. Plenty of systems make exactly the opposite mistake — pinning human processes down and letting the constraints on money go slack.
The life cycle of a price: check the fact before the commitment, freeze it after
Price has a clear life line in this system:
- In the cart: don’t store it, always read the current price. The host prices things live and operations backfills amounts afterwards — if the price were copied into the cart item at add-to-cart time, every backfill would need a mass rewrite. Reading the current price means zero backfill. Normalisation‘s advantage appears for the third time (the first two: the denormalisation test in the identity chapter, and the round’s big clean in the stock chapter). Because a cart item carries its source, the live price and the storefront price separate naturally — the same product, two channels, two prices, with no special handling at all.
- At the moment of sale: freeze. The order item stores the amount for good — not only because invoices and refunds need a number that stops moving, but because of the customer’s psychological contract: what you’ve paid for doesn’t get to change. Promotions settle at that moment too: coupons and free shipping, some recorded on the order item and some at the round layer (round-limited coupons), each leaving its own fact.
One line to close: a snapshot happens only at the point of commitment — always read the fact before it, never change it after. The cart is intent, the order is a commitment, and their price strategies are opposites, and both are right.
Reflections
The state machine losing to the hosts is the healthiest surrender I’ve seen
Engineers have a natural infatuation with state machines — they’re precise, provable, and beautiful on a whiteboard. But the fate of the bidding system’s state machine reminds me: a model’s job is to serve reality, not to correct it. Hosts aren’t undisciplined; their work is genuinely full of exceptions — stock lands unexpectedly, a price changes on the spot, a customer swaps at the last minute. Every exception is reasonable, and together they add up to “change whatever you want”. Build transition rules on top of that reality and everything you block is a legitimate operation. The right way to spend is to put the entire “enforce” budget on money and stock and leave “record” for everything else — constraints are a scarce resource, and they belong where breaching them costs the most.
The three layers aren’t fastidiousness; three realities each need one
If those three layers — payment, order, order item — had been split to look architecturally tidy, they probably wouldn’t have survived the first requirement change. They held because each sits on top of an independently varying reality: the customer settles once (the paying rhythm), goods arrive by round (the fulfilment rhythm), and the books have to survive invoicing and refunds (the accounting rhythm). As complicated requirements arrived one after another — cross-round merging, partial payment, round-limited coupons — the structure absorbed all of them. Complicated requirements don’t kill a structure with simple responsibilities; they kill a clever, muddled one. The test for adding a layer was never “the textbook says so”, it’s “does the reality behind this layer vary independently of the others?”
This chapter is “boring” because the earlier chapters paid the debt
Writing this one I noticed something: there’s no incident to tell. No oversell-grade explosion, no flattened API — checkout just ran, steadily. In hindsight that isn’t luck: one polymorphic cart table meant merged checkout never had to stitch two sets of logic together; orders hanging off identities meant nobody’s checkout required moving data; the ledger’s two counts meant cart→order transferred in a single transaction. Checkout is just the aggregate of facts the earlier chapters got right. The most underrated compliment in system design is “boring” — chapters that explode are easy to write, and boring chapters are hard-won. May your checkout flow be boring too.