Inventory as a ledger, not a number
The single most common defect in ecommerce systems is a mutable quantity_on_hand
column. It is fast, obvious, and wrong in two distinct ways.
The first is concurrency: read-modify-write on a shared counter loses updates under load,
which is exactly when it matters. The second is worse — when the number is wrong, and it
eventually is, there is no way to find out why. You cannot audit a number that only holds
its current value.
Every stock change is instead an immutable movement: received, reserved, picked, shipped,
returned, adjusted, each with a quantity, a location, a timestamp and a source document.
On-hand is the sum of movements for a SKU at a location.
The obvious objection is performance, and it is a real one — summing a ledger on every
product page does not scale. The resolution is a materialised balance maintained inside
the same transaction as the movement, treated as a cache of the ledger rather than as the
truth. A reconciliation job recomputes balances from the ledger periodically and alerts on
any drift. In practice drift means a bug, and having a mechanism that detects it is
worth considerably more than the small cost of maintaining both.
What this buys operationally: when a warehouse manager asks why the system says 14 and the
shelf says 12, the answer is a list of movements with source documents attached, not a
shrug.
Reservations, and the overselling problem
Availability is not on-hand. The number that matters at checkout is available to
promise — on-hand, minus reserved, plus inbound that is committed.
When an order is placed, a reservation row is written inside the same database transaction
that checks availability, taking a row-level lock on the stock line for that SKU and
location. Two concurrent orders for the last unit serialise: one commits, the other fails
its availability check and is told at checkout that the item has gone. That is a worse
customer experience than a successful order and a far better one than an apology email two
days later.
Reservations carry an expiry. An abandoned checkout releases its stock automatically
rather than holding it until someone notices. Confirmed payment converts the reservation
into an allocation; dispatch converts the allocation into a shipped movement.
The lock is deliberately narrow — one stock line, held for the duration of a short
transaction. Locking a whole product or, worse, using an application-level mutex, is how
checkout throughput dies on a flash sale.
The order state machine
Orders acquire state transitions the way software acquires features — one special case at
a time — until nobody can say with confidence what states exist. We define them up front
and enforce them:
Placed → Confirmed → Allocated → Picking → Packed → Dispatched → Delivered
↓ ↓ ↓ ↓
Cancelled Cancelled Cancelled Returned → Refunded
Each transition names who may perform it, what must be true beforehand, and what side
effects it triggers. Dispatched books the courier and sends the customer notification.
Cancelled releases reservations. Returned creates an inbound movement pending a
condition grade.
Two properties matter more than the diagram itself. Transitions are recorded as rows, so
an order’s history is complete and an exception queue can find everything that has been in
Picking for more than four hours. And side effects fire from the transition rather than
from the endpoint that caused it, so an order cancelled by a customer, by support, or by a
payment timeout all release stock identically — instead of two of the three paths
remembering to.
Payment idempotency
Payments fail in the least convenient ways: the gateway charges the card and the response
times out; the customer taps submit twice on a slow connection; a webhook is delivered
three times because the first two acknowledgements were lost.
Every payment attempt carries a client-generated idempotency key, stored before the
gateway is called. A retry with the same key returns the original result rather than
initiating a second charge. Gateway webhooks are deduplicated on the provider’s event ID
and processed in a transaction that is safe to replay.
The distinction that does the work is separating PaymentIntent — the customer’s
intention to pay this amount for this order — from PaymentAttempt, of which there may be
several. It makes the double-charge case representable and therefore preventable, rather
than a race condition that shows up as a support ticket.
Courier integrations: one interface, many adapters
Courier APIs are the least standardised systems most ecommerce operations touch. One
returns XML. Another requires a session token refreshed hourly. Their status vocabularies
overlap without agreeing — “out for delivery”, “in transit”, and “at delivery hub” mean
different things to different providers, and a couple of them will report a delivery
before the parcel has moved.
Every courier sits behind one internal interface — book, label, track, cancel — and each
adapter is responsible for translating that provider’s vocabulary into a single internal
status set. Nothing above the adapter layer knows which courier a shipment is on.
This makes rate shopping possible at all, and it means adding a courier is one adapter and
one set of tests rather than a change that ripples through the order module. It also
contains the damage when a provider changes their API without notice, which in our
experience they do.
The unglamorous part is status normalisation, and it is where the time actually goes.
Getting it right is what lets the customer-facing tracking page and the internal exception
queue read from the same data — instead of the support team learning the real status from
the courier’s own website.