Flow Control Patterns: Table-Driven vs Switch-Case State Machine
Role of this document: explains two mechanisms for encoding an "action flow" as code — table-driven (flow-as-data + a generic engine) and a switch-case state machine (flow-as-code) — their differences, pros and cons, selection criteria, and examples. A concept document, for reference when designing a flow engine.
1. The core distinction
| table-driven | switch-case state machine | |
|---|---|---|
| What the flow is | Data (a step / transition table) | Code (one case per state) |
| Who executes it | A single fixed generic engine reads the table and runs it | Each case runs itself and decides its own next step |
| Changing the flow | Change data (add / edit a row) | Change code (add / edit a case block) |
In finite-state-machine (FSM) theory the two are equivalent — they are two encodings of the same state machine, declarative vs imperative. The difference is entirely in engineering ergonomics (readability, debuggability, configurability), not in computational power.
The FSM above is written out twice below — once table-driven, once switch-case — and the observable behavior is identical.
2. Table-driven example
The flow is an inert data table; the only "live" code is the RunSequence engine.
// ── flow = data ──────────────────────────────
enum OpKind { MoveAbs, SetIO, WaitDI }
sealed class Step
{
public OpKind Kind;
public string Name;
public double Param; // MoveAbs→position; SetIO→DO value; WaitDI→DI index
public int TimeoutMs;
}
static readonly Step[] LoadFlow =
{
new Step { Kind = OpKind.MoveAbs, Name = "Raise", Param = 120.0, TimeoutMs = 3000 },
new Step { Kind = OpKind.SetIO, Name = "GripperOpen", Param = 1, TimeoutMs = 500 },
new Step { Kind = OpKind.WaitDI, Name = "WaitInPos", Param = 7, TimeoutMs = 2000 },
};
// ── engine = the only live code (shared by all flows) ──────
void RunSequence(Step[] flow)
{
foreach (Step s in flow)
{
switch (s.Kind) // ← note: this switch dispatches on "primitive kind", not "flow state"
{
case OpKind.MoveAbs: MoveAbs(s.Param); PollDone(s.TimeoutMs, s.Name); break;
case OpKind.SetIO: SetIO((int)s.Param); PollDone(s.TimeoutMs, s.Name); break;
case OpKind.WaitDI: WaitDI((int)s.Param, s.TimeoutMs, s.Name); break;
}
}
}
Key clarification: a table-driven design often still has a
switchinside, but it dispatches over a fixed, small set of primitive kinds (move / setIO / wait), not over flow states. The flow itself lives in thatStep[]table. Adding a step = adding a data row; the engine never changes.
This project's ActionTech used this design before commit 5ac5ec9 (since removed): StationFlows held one step table per station, and StationActionService.RunSequence was the generic engine. The core loop looked like this:
private void RunSequence(EStationId station)
{
IReadOnlyList<StationFlowStep> steps = StationFlows.Get(station); // flow = data
for (int i = 0; i < steps.Count; i++) // one loop runs everything
{
StationFlowStep step = steps[i];
if (step.IsConvStop) { /* stop conveyor */ continue; }
object payload = step.BuildPayload(station, _paramGetter); // ← lambda factory (see §6 criterion)
uint taskId = SubmitByKind(step.Kind.Value, payload);
while (true) { /* poll until Done / Error / Aborted */ }
}
}
3. Switch-case state machine example
The same FSM, rewritten imperatively: one case per state, each deciding its own next case.
void Scenario()
{
switch (iStepIndex)
{
// ── Raise ──
case 100: MoveAbs(120.0); GoCase(110); break;
case 110:
if (Done) GoCase(200);
else if (Timeout(3000)) Alarm("Raise timed out");
break;
// ── GripperOpen ──
case 200: SetIO(do: 1); GoCase(210); break;
case 210:
if (Done) GoCase(300);
else if (Timeout(500)) Alarm("Gripper timed out");
break;
// ── WaitInPos ──
case 300:
if (DI[7]) GoCase(End);
else if (Timeout(2000)) Alarm("In-position timed out");
break;
}
}
Convention: each logical step occupies a hundreds block (100 action / 110 poll; 200 / 210…), and GoCase(n) switches state and records the entry time for Timeout() to check against.
This project's current ActionTech (after 5ac5ec9) uses this approach, and because PLC-as-Service no longer drives IO directly, each action becomes "submit a PLC atomic task + poll":
// ProcAction_LdLoad.cs (excerpt)
case 100:
Submit(new StCmdMoveAbs
{
eAxisId = EAxisId.eAX_Barcode_LD_Z,
lrPos = P("BarcodeScanLD-Up", "Pos"),
lrVel = P("BarcodeScanLD-Up", "Vel"),
tTimeout = (uint)P("BarcodeScanLD-Up", "Timeout"),
}, EPlcTaskKind.eMoveAbs);
EQRunCase.GoCase(110);
this.iStepIndex = EQRunCase.GetCase();
break;
case 110:
switch (Poll((int)P("BarcodeScanLD-Up", "Timeout"), 0, "BarcodeScanLD-Up"))
{
case EStepPoll.eRunning: break;
case EStepPoll.eDone: EQRunCase.GoCase(200); this.iStepIndex = EQRunCase.GetCase(); break;
case EStepPoll.eError:
case EStepPoll.eTimedOut: ConvStop(); Finish(EStationActionResult.eError, LastErrorMsg); break;
case EStepPoll.eAborted: ConvStop(); Finish(EStationActionResult.eAborted, LastErrorMsg); break;
}
break;
Note: parameters can still be externalized (
P("step","field")reads from a parameter store), and a step's tunable fields can still be described by a pure schema table (to feed the UI / parameter page). "Parameters as a table" and "flow as a table" are two different things — the current design keeps the flow as switch-case, and only the parameter / UI metadata is a table.
4. Side-by-side comparison
| Aspect | table-driven | switch-case |
|---|---|---|
| Adding a step | Add a data row | Add a case block |
| Duplication | Engine written once (submit/poll/timeout centralized) | Each state repeats the rhythm (mitigated by helpers) |
| Branching flexibility | Weak — conditional jumps / retries / nested waits must be encoded into data, which turns ugly fast | Strong — arbitrary control flow written on the spot |
| Step heterogeneity | Requires homogeneity (all reducible to a few primitives) | Allows heterogeneity — each step can be entirely different |
| On-site debugging | Poor — every step runs through the same loop, a breakpoint can't tell "which step it's stuck on" | Good — case 110 is that step; breakpoint / step-into is intuitive |
| Runtime configurability | Yes — the flow can be loaded from file / recipe / DB, no recompile | No — it's compiled in |
| Tooling | Enumerable / visualizable / verifiable (e.g. "does every state have a timeout?") | Requires parsing the source code |
| Drift risk | Single source of truth (table is the flow) | If a parallel schema table is also kept, table and case can diverge |
5. When to use which
Use table-driven when:
- Steps are homogeneous — all reducible to a few primitives (move / setIO / wait), differing only in parameters.
- The flow is roughly linear or has a regular transition structure.
- You need the flow data to be configurable: recipe-driven, customer-editable, runtime-loaded, many similar variants.
- There are many similar flows and you don't want to copy-paste the engine.
- You want tooling: visualization, validation, code generation.
Use switch-case when:
- Steps are heterogeneous — each step does something fundamentally different, with many special cases.
- Lots of irregular branching: conditional jumps, retries, nested sub-states, per-step error recovery.
- On-site traceability outweighs DRY — engineers (possibly without AI assistance) need to read it case by case and set breakpoints.
- The flow is stable and being compiled in is fine; no runtime reconfiguration needed.
- Team convention is switch-case (shared mental model, lower cognitive cost).
6. Criterion: does a data row need to hold a lambda?
One signal you can judge on the spot:
In a pure table-driven design, every row is inert data (int, enum, string), and the engine is the only live code.
Once a data row starts carrying a Func<> / delegate / BuildPayload — something that "packs code into data" — you're forcing it, which means the steps aren't really that homogeneous. This project's old engine's step.BuildPayload(station, _paramGetter) had exactly this smell: it had already admitted "parameters alone can't describe this step," so it smuggled code back into the table. At that point, switch-case is usually more honest.
The reverse also holds: if a switch has one case after another copy-pasting nearly identical submit/poll with only the parameters changing — that's forcing switch-case, and you should consider extracting it into a table.
7. Applied to this project
- ENR_DUC (legacy machine): the
casebodies directlySetDO/ readDI/ do various inline conditions — highly heterogeneous, many branches → switch-case is right; forcing it into a table would require a flag per special case, getting murkier the more you describe. - Tray-flip machine ActionTech: the steps are homogeneous (all "submit a PLC atomic task + poll"), so a table would fit well in theory; but this machine ranks "on-site debugging case by case, even without AI" above "avoiding duplication," so it chose switch-case. The cost is drift between the parameter schema table and the
casebodies (double-write) — a trade-off made with eyes open.
In one sentence: homogeneous + needs configurability / tooling → table; heterogeneous + needs on-site traceability → switch-case. When a data row starts growing a lambda, that's the signal to switch back to switch-case.