---
title: "Flow Control Patterns: Table-Driven vs Switch-Case State Machine"
title_en: "Flow Control Patterns: Table-Driven vs Switch-Case State Machine"
description: Two ways to encode an action sequence as code — table-driven (flow-as-data + generic engine) versus a switch-case state machine (flow-as-code) — their trade-offs, selection criteria, and worked examples.
sidebar_label: Flow Control Patterns
---

# 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.

```mermaid
stateDiagram-v2
    [*] --> Raise
    Raise --> GripperOpen : Done
    Raise --> Alarm : Timeout
    GripperOpen --> WaitInPos : Done
    WaitInPos --> [*] : InPosition
    WaitInPos --> Alarm : Timeout
    Alarm --> [*]
```

> 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.

```csharp
// ── 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:

```csharp
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`.

```csharp
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":

```csharp
// 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 `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.
