A training exercise that shows four different ways to implement the same UI decision problem—from a brittle, nested branching approach to a data-driven rule engine and finally a finite-state machine (FSM).
Each view also chooses a presentational component at runtime using its respective decision mechanism. This makes the benefits (or pain) of each approach visible beyond simple string/CTA selection.
Imagine we’re building a small UI for a background “job processing” system. A user can start a job, watch it run, see when it’s ready, or recover from a failure. We support two job types—standard and premium—and a lifecycle with four states: idle, running, ready, and failed.
Each screen computes a view-model with:
- A header describing the situation (e.g., “Premium is processing”)
- A primary action (button intent): start, view, retry, or none
- A runtime-selected panel (pure, presentational component) that visually reflects the state
We implement the same business rules in four different ways to compare ergonomics, testability, and clarity.
idle-> “{Standard|Premium} job is idle”running-> “{Standard|Premium} is processing”ready-> “{Standard|Premium} result ready”failed-> “{Standard|Premium} failed”
- If
ready-> view (“Open Result”) - Else if
running-> none (“Processing…”) - Else if
idle-> start (“Start Standard” or “Start Premium”) - Else if
failed-> retry (“Retry”) - Else -> none (“No action”)
idle --start--> runningrunning --complete--> readyrunning --fail--> failedfailed --reset--> idleready --reset--> idle
By design,
failis only allowed fromrunningin the strict FSM; the UI disables invalid buttons preventing illegal transitions.
-
Anti-pattern (Branching Hell) —
features/01-branching-anti-pattern- Deeply nested
if/elsewith duplicated checks and hidden priorities. - Hard to reason about, easy to introduce dead code.
- Runtime component choosing: panel is picked with the same tangled branching (
selectPanelBranching).
- Deeply nested
-
Rule-based (Decision Table) —
features/02-decision-table- A small helper (
pickByRules) evaluates ordered rules ({ when, value }) with first-match-wins. - Facts are derived once; policy is a declarative array of rules.
- Runtime component choosing: panel is picked by rules as well (
selectPanelByRules).
- A small helper (
-
Improved Rule Engine —
features/03-decision-table-improveddecideWithCtx(ctx, rules, fallback)supports explicit priorities, lazy values, trace output, and Angular DI tokens to register rules viamulti: true.- Enables modular policy contributions from multiple features.
- Runtime component choosing: a DI-backed rule set selects which component to render (
PANEL_RULES+decideWithCtx). A separateJobPanelFallbackComponentis used as the fallback.
-
FSM Alternative —
features/04-fsm-alternative- Explicit states and events with a small
step()runner and a pure(state, type) -> view-modelmapping. - UI enables only allowed events; disallowing illegal transitions.
- Runtime component choosing: a simple state -> component map (
panelForState) returns the presentational component.
- Explicit states and events with a small
All views render one of four presentational components (no logic inside) to make state transitions visible:
IdlePanelComponentRunningPanelComponentReadyPanelComponentFailedPanelComponent
These are exported via the shared barrel: @/app/shared/ui/job-state-panels and rendered using Angular’s *ngComponentOutlet.
- View 1 (Branching):
selectPanelBranching(type, state)mirrors the nestedif/elseanti-pattern. - View 2 (Decision Table):
selectPanelByRules(type, state)uses the samepickByRuleshelper as the VM. - View 3 (Improved): DI-powered
PANEL_RULES+decideWithCtxpicks a component by priority and context. A separateJobPanelFallbackComponentis used as the fallback. - View 4 (FSM):
panelForState(state)is a direct map fromJobStateto component.
- Shows that data/decision policy can drive real UI, not just strings.
- Keeps presentational components dumb and highly reusable.
- Demonstrates different ways to avoid
if/elsesoup when selecting UI.
Our decision helpers allow value to be either a literal or a factory function. Angular components are class constructors (functions), so we must not invoke them. We added a shared guard to only call true factories:
isFactoryFunction(in@/app/shared/utils/invocation-guards) returnstrueonly for non-Angular, non-class functions.- Both
pickByRulesanddecideWithCtxuse this guard to avoid the classic error: “Class constructor X cannot be invoked without 'new'.”
The four panels are small, but in real apps you can lazy-load heavy panels (standalone components) via dynamic import() and return the component type once resolved.
Tip: if panels become very similar, consider a single configurable component with
@Input()s instead of four separate classes.
npm ci
npm start
# open http://localhost:4200npm test
npm run test:watch- Decision Table (data-driven CoR): Encode policy as ordered rules; first match wins. Easy to read and test.
- Improved Engine: Add priorities, typed context, tracing, and DI-based rule registration for modularity.
- FSM: Prefer when the domain is a strict lifecycle with mutually exclusive states and event-driven transitions.
- Runtime UI selection: The same decision mechanism that builds the VM can also pick which component to render.
FSM enforces sequence; rules express policy.
An FSM defines the legal order of events: from each state, only certain transitions are allowed. It prevents illegal jumps (e.g., you can’t “fail” unless you’re running) and makes the lifecycle deterministic.
Rules sit on top of that sequence: given the current state and facts (type, role, flags), they decide what to show or do—headers, CTAs, or which component to render.
Think: FSM = guard rails; Rules = signage.
- Add new job types or states and update the rules (Views 2–3) or transitions (View 4).
- Contribute rules via DI tokens in the Improved Engine without editing a central “god” file.
- Use the FSM when you need invariants like “this event cannot happen from that state”.
- Replace the four panels with a single configurable one if they start to drift only in visuals.
- How easily can you add or change a rule?
- Is priority clear and testable?
- Can features contribute policy without merge conflicts?
- Does the UI reflect allowed/blocked actions (especially in the FSM view)?
- Is it straightforward to swap out the presentational component based on policy?