Back to Feed
article 3m read

A received order could be set back to draft through the order API

Every endpoint in my ordering system wrote the order status its own way, and one would rewind a received order to draft. I put every legal move in one shared table, made every writer ask it, and added a history trail and a changed-only webhook for supplier writes. The race and the lost-message problems are still mine.

Article DepthPractice

Flow

How the status guard fits together

  1. 1One table

    Lists which status can follow which; the end states list nothing.

  2. 2Same check everywhere

    Every endpoint asks the table and names both states when it says no.

  3. 3New meanings

    Accept now means acknowledged, which changes what partners get back.

  4. 4History and webhook

    One row and one message per real change from a supplier endpoint; none for a retry or an internal change.

  5. 5Gaps left

    Two requests at once, saves that are not joined, messages not retried.

One endpoint in the ordering system would set a received order back to draft. It checked that the new status was a known word. It never looked at the order's current status.

Back then there were only four states. Each place that wrote the status made up its own rule. The supplier reply endpoint turned a "needs revision" into submitted and only refused a received order.

Then this change added more states, like out for delivery and delivered. With the old rule, a late "needs revision" on an order already on a truck would send it back to submitted. I never saw that happen. I worked it out by reading the code.

No way back once it's on the truck

Now there's one table. For each state, it lists the states that may come next. Every writer asks it before writing. The core is tiny.

A move is fine if the order stays in the same state or the new state is on the old state's list.

A few rules in the table matter. Early states can step back to submitted, so a supplier can still ask for changes. Once an order is out for delivery, it only moves forward or gets cancelled. Received and cancelled are the end. Staying in the same state is fine, so a retry does no harm. A stored status the table doesn't know is refused, not guessed at.

The sequence every writer follows

Each endpoint loads the order, checks the stored status, works out the new one, and asks the table. If the answer is no, it sends back a conflict that names both states. A partner can see exactly which move was blocked.

Accepted stopped meaning confirmed

The new states changed old meanings. An accept now means acknowledged, not confirmed. Anyone matching on the old word gets something new back, and I didn't find any warning period in the change. If you do this to a public API, announce it.

Later that day, endpoints for marking delivered and received used the same table. One catch: an order can't be marked delivered without going through out for delivery first.

A trail, and a message only when something moved

On the supplier endpoints, each real change saves a history row with the old status, the new one, the reason and who did it. Then the supplier's webhook gets a message, but only when the status really changed. A change made through the internal endpoint gets checked, but it saves no row and sends no message.

Two requests at once, and saves that aren't joined

The code reads the status and then writes it as a separate step, so two requests at once can both get through. The update and the history row aren't saved together, so a crash can leave a gap. The webhook isn't retried, so a supplier who misses one has to check the history.

I'd also add one test that walks every state in the table and makes sure it can still reach received or cancelled.

Keep reading

Pick up a connected idea or branch into a nearby one.

5 paths forward
Related idea

Paying customers matched by email, even the addresses the billing code made up itself

The lookup that decided whether an organization already had a payment customer searched by an email my code sometimes guessed or generated, and could hand one org's billing record to another. I moved identity onto the identity provider's org ID and tied retries to it, but the same commit left an ignored retry flag, a delete check that reads error wording, a gutted seat counter, and no tests.

via #idempotency · #reliability · #api-design