Journal · Software quality
Explicit vs implicit state machines: find the one hiding in your system
Anything that moves through steps is a state machine. The only question is whether anyone wrote it down. Break an order below, two ways, and count what the unwritten version lets through.
Every process is a state machine
An order is placed, paid, shipped. An invoice is drafted, sent, paid, or written off. A purchase is requested, approved, ordered, received. Each of these is a state machine: a thing that is always in exactly one of a small number of states, and that moves between them only when particular events happen.
That is true whether or not anyone wrote it down. The difference between an explicit state machine and an implicit one is not whether the machine exists. It is whether there is one place you can point to and read it.
The idea is old. George Mealy and Edward Moore described these machines for switching circuits in 1955 and 1956, and David Harel's statecharts (1987) gave engineers a way to draw them for large systems. What is new in most businesses is only how many of them nobody has drawn.
An explicit machine has a list of states and a table of allowed moves, and anything not in the table is refused. An implicit machine has neither. Its states are scattered across yes or no fields, empty dates and status text, and its rules live in a dozen separate pieces of code that each check the one thing their author was thinking about that day. The machine is still there. It is just the sum of all those checks, and nobody has ever seen the sum.
Break an order
Below is one order kept two ways. On the left, the way most systems grow: four fields, paid, shipped, cancelled and refunded, and four handlers. Each handler is reasonable on its own. Pay only if not already paid. Ship only if paid. Cancel only if not yet shipped. Refund only if paid. On the right, the same order with one state field and a table of allowed moves.
Try it: one order, two ways of keeping it
Implicit: four yes or no fields
New
Explicit: one state and a table
New
Nothing has happened yet. Try Cancel, then Pay.
Press Cancel, then Pay. The left side now holds a cancelled order with the money kept, and its state has no name, so the toy prints the only word that fits. Press Ship and it ships the cancelled order too. No single handler is wrong. The cancel handler never thought about payment, and the pay handler never thought about cancellation, because each was written by someone looking at one button.
Four yes or no fields can sit in 16 combinations. The business means 5 of them: new, paid, shipped, cancelled, refunded. The other 11 are states nobody named, so nobody wrote a screen, a report or a rule for them. In this model 5 of those 11 can actually be reached by pressing buttons in an ordinary order.
Try every order at once
Your browser will play out every possible order of that many events, in both versions, and count.
The counts are exhaustive, not sampled. Across all 256 possible orders of four events, the implicit version ends in a state nobody named in 118. Across all 4,096 orders of six events, in 2,874. The explicit version never does, because a move that is not in its table does not happen. It says no, and says why.
A model built to show the mechanism, with four handlers chosen to look sensible. Its table is also strict on purpose: it has no returns and no cancelling after payment. Many shops allow both, and a real table would add them as cells, which would make one of the five unnamed combinations (paid, shipped and refunded) a named state called returned, so part of the explicit side's clean record comes from that stricter policy. Real systems have more fields and more handlers, and the counts above describe this model only. They are not a measurement of any real system, and none of them comes from a client.
The same order, written down
Written as a table it becomes something you can check line by line. Every state against every event, 20 cells, and each cell is either a move or a refusal:
| State | pay | ship | cancel | refund |
|---|---|---|---|---|
| New | → Paid | refused | → Cancelled | refused |
| Paid | refused | → Shipped | refused | → Refunded |
| Shipped | refused | refused | refused | refused |
| Cancelled | refused | refused | refused | refused |
| Refunded | refused | refused | refused | refused |
That table is the whole specification. It is also the whole test plan: 20 cells, 20 tests, and a test that tries pay on a cancelled order expects a refusal. With the implicit version there is nothing to count. You cannot write a test for every case when no document says what the cases are.
Where implicit machines hide
Nobody sets out to build one. They accumulate. These are the usual places to look.
A cluster of yes or no fields on one record
is_approved, is_sent, is_void, is_archived. Two flags that interact are already a four state machine; three are eight. Count the combinations, then count the ones the business has a word for. The gap is the risk.
Empty dates standing in for states
"Shipped" means shipped_on is filled in; "cancelled" means cancelled_on is. Now both can be filled in, and every report has to decide which one wins. Each report decides on its own.
A status field that anything can write
A status field with a dropdown looks explicit. It is only explicit if every write goes through one function that checks the move. If the import, the API, a scheduled job and a server script can each set status = "Shipped" directly, the list of states is written down and the rules are not.
A hidden button used as a guard
The form hides Refund until the order is paid, so the refund can only happen after payment. Except the hiding is a display rule. It does not stop the same change arriving through the API, a spreadsheet import or a script, unless something on the server checks it too.
Events that arrive out of order, or twice
A payment provider's notice arrives after the customer has cancelled. A courier's "delivered" message arrives twice. An implicit machine applies whatever arrives. An explicit one asks whether that event is allowed from the state the order is in now, which is the question that matters.
The answer cannot always be no. Refusing is right for a button a person presses. A payment notice reports something that has already happened, and refusing it does not send the money back; it only means the system no longer records money it holds. So in a real table that cell is not a refusal. It is a move to a state such as paid after cancel, which records the money and starts a refund or puts it in front of a person. The toy refuses it to keep the table small. A real one should not.
A spreadsheet, a whiteboard, an inbox
Sometimes the state is not in the system at all. It is a colour in a spreadsheet, or which folder an email sits in, or what one person remembers. That is the most implicit machine there is, and the hardest to hand over.
In Frappe and ERPNext
We build on Frappe and ERPNext, so here is where the line falls there. Frappe gives every submittable document an explicit three state machine at its core, its docstatus: draft, submitted, cancelled. A submitted document cannot be edited except for fields marked as allowed after submission, and a cancelled one cannot come back; you amend it, which makes a new document. That is a machine with a refusal built in, as long as the change goes through the document. A direct database write skips those checks, which is why every way in has to be checked, not just the form.
On top of that, Frappe's Workflow lets you declare states, the actions that move between them, and which role may take each action. That is an explicit machine you can read in one screen.
The implicit ones appear in customisation: a custom tick box called Approved plus a server script that checks it, a second tick box added a year later, a status set by a client script in the browser. Each was a small, sensible change. Together they are a machine nobody declared. A status computed from other fields, such as how much of an order has been delivered and billed, is fine as long as one piece of code computes it and nothing else writes it. ERPNext's sales order mostly works this way, with a few states a person sets by hand, such as On Hold and Closed, set through one checked function.
When implicit is fine
Not every flag needs a machine. A single archived tick box that nothing else depends on is two states and one move, and a table for it would be ceremony. The trouble starts at the second flag that interacts with the first, at the third value of anything, or at the first event that can arrive from outside: a payment, a webhook, an import. That is when to write the machine down.
Making one explicit
Programmers have a phrase for the goal, usually credited to Yaron Minsky: make illegal states unrepresentable. One state field with a fixed list of values cannot hold "cancelled and shipped", because there is no such value. The conversion is mechanical, and it can be done one record type at a time.
- Name the states the business already uses. Ask the people who do the work, not the code. If they say "on hold", that is a state, whether or not a field holds it.
- Draw the table. Every state against every event. Fill in every cell with a move or a refusal. The cells nobody can fill in are the open questions, and they were open before you drew the table too.
- Replace the flags with one state field. Keep the old fields read only for a while if reports depend on them, and derive them from the state.
- Route every write through one function. The form, the API, imports, scheduled jobs and webhooks all call the same transition, which checks the table. Where the database can enforce the list of states itself, let it.
- Log every move. From, to, which event, who, when. An explicit machine gives you an audit trail almost free, and it is the first thing anyone asks for after a mistake.
- Test every cell. The table is the test list.
Before converting, find out which unnamed combinations already exist in the data. Count the records in each combination of the old fields. Any count in a combination the business has no name for is a record somebody will have to decide about by hand, and it is better to find them before the migration than during it.
How we check for it
From November 2026 we are adding this check to Highland Willow's quality checks on the systems we build and change. It works like this. For each kind of record that moves through steps, we write down the states, count the combinations the fields allow against the ones the business names, draw the state and event table, and find the line of code or configuration that enforces each cell, or mark it as unguarded. Then we check that every way in (the form, the API, imports, scheduled jobs and incoming webhooks) goes through that guard, and what happens when the same event arrives twice or out of order. Anything unguarded that moves money, or that would break an agreement about customer data, is fixed before it ships, or the system's owner decides in writing to carry it for now, and we record that decision with its date.
What this does not prove
The toy is a model chosen to show a mechanism. Its handlers were written to look reasonable, and real code can be better or worse. An explicit machine does not make a system correct: it makes the rules visible, so they can be argued about, tested and changed in one place. The table can still be wrong. It is just wrong where everyone can see it.
Sources
- Mealy, G. H. (1955). "A method for synthesizing sequential circuits." Bell System Technical Journal 34(5), 1045–1079.
- Moore, E. F. (1956). "Gedanken-experiments on sequential machines." In Automata Studies, Princeton University Press, 129–153.
- Harel, D. (1987). "Statecharts: a visual formalism for complex systems." Science of Computer Programming 8(3), 231–274.
- Minsky, Y. (2010). "Effective ML," a talk at Harvard, and (2011) "Effective ML Revisited," Jane Street Tech Blog. The source of the phrase "make illegal states unrepresentable".
- Frappe Framework source, version 15:
frappe/model/document.py(check_docstatus_transition, update after submit) andfrappe/model/workflow.py. ERPNext source, version 15:erpnext/controllers/status_updater.py(status_map).
The order toy runs entirely in your browser. Nothing you press in it is sent anywhere. None of its numbers come from any client.