Lesson 18 / 30
State
Comparing keeping the shipment life cycle in five flags and nested conditionals with turning each state into an object that knows its own transitions: the number of invalid transitions accepted across thirty state–event pairs, cyclomatic complexity, the number of representable combinations, the number of lines changed when a new state is added, and the pattern's cost as a delayed appearance of a transition-target error.
Contents
In the template, the order was fixed; which step would run was known from the start. In the shipment itself, which operation is valid depends on the current state. A shipment can be created, collected, in transit, out for delivery, delivered, or returned; a shipment that has not been collected cannot go out for delivery, and a delivered shipment cannot be returned. Today, these rules are checked with five flags and nested conditionals.
The state machine concept was established once before, in the Software Quality and Testing curriculum: the Error Life Cycle lesson described it there with a state and transition table, and the transitions were used to produce test cases. What is new here is not the machine itself but its expression through objects. The State pattern turns each state into an object and tells that object only the transitions it permits. The numbers to measure are the number of accepted invalid transitions, cyclomatic complexity, and the number of representable flag combinations.
The Life Cycle Held in Flags
There are five flags and five events. Each event sets up its own condition; the state’s name is derived from checking the flags in sequence.
mkdir -p flags state
// flags/shipment.mjs — state kept in five flags, transitions in nested conditionals export const shipment = (code) => { const d = { collected: false, inTransit: false, outForDelivery: false, delivered: false, returned: false }; const stateName = () => d.returned ? "returned" : d.delivered ? "delivered" : d.outForDelivery ? "out_for_delivery" : d.inTransit ? "in_transit" : d.collected ? "collected" : "created"; return { code, stateName, event(name) { if (name === "collect") { if (d.collected === false && d.delivered === false) { d.collected = true; return true; } return false; } if (name === "depart") { if (d.collected && d.inTransit === false) { d.inTransit = true; return true; } return false; } if (name === "outForDelivery") { if (d.inTransit && d.outForDelivery === false) { d.outForDelivery = true; return true; } return false; } if (name === "deliver") { if (d.inTransit && d.delivered === false && d.returned === false) { d.delivered = true; return true; } return false; } if (name === "return") { if (d.inTransit && d.returned === false) { d.returned = true; return true; } return false; } return false; }, }; };
Turning the State into an Object
In the second version, each state is an object and carries its own set of transitions. The transition set is a mapping from an event name to the name of the state that follows it.
// state/states.mjs — each state knows only its own transitions const state = (name, transitions) => ({ name, allows: (event) => Object.hasOwn(transitions, event), next: (event) => transitions[event], }); export const STATES = { created: state("created", { collect: "collected" }), collected: state("collected", { depart: "in_transit" }), in_transit: state("in_transit", { outForDelivery: "out_for_delivery", return: "returned" }), out_for_delivery: state("out_for_delivery", { deliver: "delivered", return: "returned" }), delivered: state("delivered", {}), returned: state("returned", {}), };
// state/shipment.mjs — the transition decision is asked of the state object import { STATES } from "./states.mjs"; export const shipment = (code) => { let current = STATES.created; return { code, stateName: () => current.name, event(name) { if (current.allows(name) === false) return false; current = STATES[current.next(name)]; return true; }, }; };
The shipment object no longer knows which event is valid in which state; it asks the state object it holds for the decision. This is the state-machine counterpart of the tell, don’t ask principle: the knowledge sits where the decision is made.
Trying All Thirty Pairs
The measurement is done by trying all five events in each of the six states: thirty pairs. The expectation table and the path leading to each state sit in a separate file.
// table.mjs — valid transition table and the path to reach each state export const EVENTS = ["collect", "depart", "outForDelivery", "deliver", "return"]; export const PATH = { created: [], collected: ["collect"], in_transit: ["collect", "depart"], out_for_delivery: ["collect", "depart", "outForDelivery"], delivered: ["collect", "depart", "outForDelivery", "deliver"], returned: ["collect", "depart", "return"], }; export const VALID = { created: { collect: "collected" }, collected: { depart: "in_transit" }, in_transit: { outForDelivery: "out_for_delivery", return: "returned" }, out_for_delivery: { deliver: "delivered", return: "returned" }, delivered: {}, returned: {}, };
// run.mjs — tries all thirty state-event pairs in both versions import { shipment as flagShipment } from "./flags/shipment.mjs"; import { shipment as stateShipment } from "./state/shipment.mjs"; import { VALID, EVENTS, PATH } from "./table.mjs"; function probe(create, label) { let acceptedInvalid = 0; let rejectedValid = 0; const rows = []; for (const [stateName, path] of Object.entries(PATH)) { const marks = []; for (const event of EVENTS) { const t = create("TS-1"); for (const e of path) t.event(e); const accepted = t.event(event); const valid = Object.hasOwn(VALID[stateName], event); if (accepted && valid === false) acceptedInvalid += 1; if (accepted === false && valid) rejectedValid += 1; marks.push(accepted === valid ? "." : accepted ? "A" : "R"); } rows.push(` ${stateName.padEnd(17)} ${marks.join(" ")}`); } console.log(`${label} (${EVENTS.join(" ")}):`); for (const r of rows) console.log(r); console.log(` ${label.padEnd(17)} accepted invalid=${acceptedInvalid} rejected valid=${rejectedValid}`); } probe(flagShipment, "flags"); probe(stateShipment, "state");
flags (collect depart outForDelivery deliver return): created . . . . . collected . . . . . in_transit . . . A . out_for_delivery . . . . . delivered . . . . A returned . . A . . flags accepted invalid=3 rejected valid=0 state (collect depart outForDelivery deliver return): created . . . . . collected . . . . . in_transit . . . . . out_for_delivery . . . . . delivered . . . . . returned . . . . . state accepted invalid=0 rejected valid=0
Three versus zero. The flag version rejects no valid transition, so it is not broadly broken;
it allows too much. The three A marks are three concrete bugs: a shipment that has not
gone out for delivery can be marked delivered, a delivered shipment can be returned, and a
returned shipment can still be handed out for delivery. All three share the same source: every
event sets up its own condition, and none of them asks “what state am I in right now” from a
single place.
Complexity and the Representation Space
// complexity.mjs — cyclomatic complexity by counting decision points import { readFileSync, readdirSync } from "node:fs"; const DECISION = /\bif\b|&&|\|\||\?|\bcase\b|\bwhile\b|\bfor\b/g; for (const dir of ["flags", "state"]) { let decisions = 0; let lines = 0; const files = readdirSync(dir).sort(); for (const f of files) { const text = readFileSync(`${dir}/${f}`, "utf8").replace(/^\/\/.*$/gm, ""); decisions += (text.match(DECISION) ?? []).length; lines += text.split("\n").filter((s) => s.trim().length > 0).length; } console.log(`${dir.padEnd(7)} files=${files.length} lines=${lines} decision points=${decisions} cyclomatic complexity=${decisions + 1}`); }
flags files=1 lines=47 decision points=21 cyclomatic complexity=22 state files=2 lines=26 decision points=1 cyclomatic complexity=2
Twenty-two versus two. The difference comes from moving decisions from code to data: the transition rules are no longer branches but map entries. The second number shows why this leaves no room for error.
// cost.mjs — representable combinations and the delay from a mistyped transition target import { cpSync, readFileSync, writeFileSync } from "node:fs"; import { STATES } from "./state/states.mjs"; const FLAGS = 5; console.log(`flags: representable combinations=${2 ** FLAGS} valid states=${Object.keys(STATES).length}`); console.log(`state: representable combinations=${Object.keys(STATES).length} valid states=${Object.keys(STATES).length}`); cpSync("state", "state-broken", { recursive: true }); writeFileSync( "state-broken/states.mjs", readFileSync("state/states.mjs", "utf8").replace('outForDelivery: "out_for_delivery"', 'outForDelivery: "out_for_deliveryy"'), ); const t = (await import("./state-broken/shipment.mjs")).shipment("TS-3"); let eventNo = 0; for (const e of ["collect", "depart", "outForDelivery", "deliver"]) { eventNo += 1; try { const accepted = t.event(e); console.log(`event ${eventNo} ${e.padEnd(15)} accepted=${accepted ? 1 : 0}`); } catch (err) { console.log(`event ${eventNo} ${e.padEnd(15)} ${err.constructor.name} thrown`); break; } }
flags: representable combinations=32 valid states=6 state: representable combinations=6 valid states=6 event 1 collect accepted=1 event 2 depart accepted=1 event 3 outForDelivery accepted=1 event 4 deliver TypeError thrown
Thirty-two versus six. Five independent flags produce thirty-two combinations, and twenty-six of them correspond to no real state; being out for delivery without being in transit, or being both delivered and returned, are representable. Because the state object holds a single variable, its representation space equals the set of valid states.
The output’s last four lines give the first item of the cost. When a transition target is
misspelled by one letter, the outForDelivery event is accepted, and the error surfaces
only on the next event. Because transitions are data, target names are not code, and a
misspelled name breaks not where it was written but where it is used. The second item of cost
is the file count: one file versus two, and understanding whether an event is accepted or not
requires reading both.
Adding a New State
An on_hold state and two new events are requested for shipments awaiting address
confirmation: an in-transit shipment is held with hold and set back in motion with resume.
The script applies the change to both versions, counts the changed lines, and runs the same
event sequence in both.
// new-state.mjs — adds the "on_hold" state to both versions and measures the change import { cpSync, readFileSync, writeFileSync } from "node:fs"; cpSync("flags", "flags-new", { recursive: true }); cpSync("state", "state-new", { recursive: true }); const edit = (path, search, next) => { const before = readFileSync(path, "utf8"); writeFileSync(path, before.replace(search, next)); }; edit( "flags-new/shipment.mjs", " const d = { collected: false, inTransit: false, outForDelivery: false, delivered: false, returned: false };", " const d = { collected: false, inTransit: false, outForDelivery: false, delivered: false, returned: false, onHold: false };", ); edit( "flags-new/shipment.mjs", ' d.returned ? "returned" : d.delivered ? "delivered" : d.outForDelivery ? "out_for_delivery" : d.inTransit ? "in_transit" : d.collected ? "collected" : "created";', ' d.returned ? "returned" : d.delivered ? "delivered" : d.onHold ? "on_hold" : d.outForDelivery ? "out_for_delivery" : d.inTransit ? "in_transit" : d.collected ? "collected" : "created";', ); edit( "flags-new/shipment.mjs", ' if (name === "outForDelivery") {\n if (d.inTransit && d.outForDelivery === false) {', ` if (name === "hold") { if (d.inTransit && d.onHold === false && d.outForDelivery === false) { d.onHold = true; return true; } return false; } if (name === "resume") { if (d.onHold) { d.onHold = false; return true; } return false; } if (name === "outForDelivery") { if (d.inTransit && d.outForDelivery === false && d.onHold === false) {`, ); edit( "state-new/states.mjs", ' in_transit: state("in_transit", { outForDelivery: "out_for_delivery", return: "returned" }),', ` in_transit: state("in_transit", { outForDelivery: "out_for_delivery", return: "returned", hold: "on_hold" }), on_hold: state("on_hold", { resume: "in_transit", return: "returned" }),`, ); const diffLines = (a, b) => { const x = readFileSync(a, "utf8").split("\n"); const y = readFileSync(b, "utf8").split("\n"); const set = new Set(x.map((s) => s.trim())); return y.filter((s) => s.trim().length > 0 && set.has(s.trim()) === false).length; }; console.log(`flags: lines added/changed=${diffLines("flags/shipment.mjs", "flags-new/shipment.mjs")}`); console.log(`state: lines added/changed=${diffLines("state/states.mjs", "state-new/states.mjs")}`); const b = (await import("./flags-new/shipment.mjs")).shipment("TS-2"); const c = (await import("./state-new/shipment.mjs")).shipment("TS-2"); for (const t of [b, c]) { const trail = []; for (const e of ["collect", "depart", "hold", "outForDelivery", "resume", "outForDelivery", "deliver"]) { trail.push(`${e}=${t.event(e) ? "1" : "0"}`); } console.log(`${t.stateName().padEnd(10)} ${trail.join(" ")}`); }
flags: lines added/changed=9 state: lines added/changed=2 delivered collect=1 depart=1 hold=1 outForDelivery=0 resume=1 outForDelivery=1 deliver=1 delivered collect=1 depart=1 hold=1 outForDelivery=0 resume=1 outForDelivery=1 deliver=1
Nine lines versus two lines, and both versions give the same sequence of acceptances and the
same final state for the same event sequence. Part of the nine lines goes into an existing
condition — a third check was added to the outForDelivery condition — so the new state also
puts the existing transitions’ behavior at risk. In the state version, none of the existing
transition lines were touched.
Summary
- The State pattern turns each state into an object that knows its own transition set; the shipment object does not know which event is valid, it asks the state object it holds.
- Trying all thirty state–event pairs, the flag version accepted 3 invalid transitions and the state version accepted 0; neither version rejected any valid transition.
- Cyclomatic complexity dropped from 22 to 2; the number of representable combinations dropped from 32 to 6, eliminating 26 meaningless flag combinations.
- Adding a new state and two events changed 9 lines in the flag version and 2 lines in the state version; both versions gave the same result for the same event sequence.
- Cost: the file count rose from 1 to 2, and a misspelled transition target surfaced not on the event where it was written but on the next one.
Next Step
The state object answers one question from a single place: is this event valid here. On the library’s fee-correction side, the question cannot be asked in one place. A contracted-customer discount, a volume discount, a campaign discount, and a manually entered adjustment can be applied to a shipment in sequence; some of the rules do not fit the shipment and are skipped, and some cut the chain short once applied. Today, these rules sit in a single body with nested conditionals, and their order is buried inside that body. The next lesson counts this body’s decision points, measures the lines edited when the rule order changes, then builds a chain that passes the request between handlers and recalculates the same numbers.
To keep your progress and take notes, Log in
My notes
Log in to take notes.