Skip to main content

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-drivenswitch-case state machine
What the flow isData (a step / transition table)Code (one case per state)
Who executes itA single fixed generic engine reads the table and runs itEach case runs itself and decides its own next step
Changing the flowChange 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 switch inside, but it dispatches over a fixed, small set of primitive kinds (move / setIO / wait), not over flow states. The flow itself lives in that Step[] 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

Aspecttable-drivenswitch-case
Adding a stepAdd a data rowAdd a case block
DuplicationEngine written once (submit/poll/timeout centralized)Each state repeats the rhythm (mitigated by helpers)
Branching flexibilityWeak — conditional jumps / retries / nested waits must be encoded into data, which turns ugly fastStrong — arbitrary control flow written on the spot
Step heterogeneityRequires homogeneity (all reducible to a few primitives)Allows heterogeneity — each step can be entirely different
On-site debuggingPoor — 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 configurabilityYes — the flow can be loaded from file / recipe / DB, no recompileNo — it's compiled in
ToolingEnumerable / visualizable / verifiable (e.g. "does every state have a timeout?")Requires parsing the source code
Drift riskSingle 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 case bodies directly SetDO / read DI / 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 case bodies (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.