Final StretchOrdered learning track

Configurable Workflow UI

Learn React Hooks, State Management, Component Composition, Context Passing, Component Communications & Orchestration - Part 102

Configurable workflow UI as a metadata-driven architecture: schema, renderer, registry, workflow machine, permissions, validation, extension points, and guardrails against building an unmaintainable mini programming language.

7 min read1398 words
PrevNext
Lesson 102123 lesson track102–123 Final Stretch
#react#workflow#configuration#metadata-driven-ui+3 more

Part 102 — Configurable Workflow UI

Configurable workflow UI adalah salah satu arsitektur paling berbahaya di frontend.

Jika didesain baik, ia membuat sistem enterprise bisa mendukung banyak proses tanpa deploy frontend setiap kali policy/form/workflow berubah.

Jika didesain buruk, ia berubah menjadi:

JSON-based programming language
+ hidden business logic
+ impossible debugging
+ weak type safety
+ inconsistent accessibility
+ configuration hell

Tujuan part ini bukan membuat semua UI menjadi dynamic.

Tujuannya adalah memahami kapan configurable UI masuk akal, bagaimana membatasi domain-nya, dan bagaimana membangun renderer yang tetap defensible, testable, observable, dan maintainable.


1. Masalah yang Ingin Diselesaikan

Dalam aplikasi case management, regulatory workflow, claims, onboarding, compliance review, atau internal operations, variasi UI sering mengikuti variasi proses:

Case type A punya 5 step.
Case type B punya 9 step.
Region X wajib field tambahan.
Risk high butuh approval tambahan.
Legal hold mengubah field menjadi readonly.
Supervisor bisa override beberapa step.
Feature flag mengaktifkan section baru.
Policy version lama harus tetap bisa dibuka.

Hardcode semua variasi menghasilkan:

if region === "X"
if risk === "HIGH"
if workflowVersion === "v3"
if user.role === "supervisor"
if caseType === "SPECIAL_INVESTIGATION"

Lama-lama UI menjadi policy engine informal.

Configurable workflow UI memindahkan variasi yang memang data-driven ke konfigurasi yang tervalidasi.


2. Bukan Semua Harus Configurable

Decision pertama: apakah UI ini layak dibuat configurable?

Gunakan matrix:

PertanyaanJika YaJika Tidak
Variasi sering berubah tanpa release?Configuration bergunaHardcode lebih sederhana
Variasi bisa dimodelkan sebagai data?Metadata-driven layakButuh custom component
Domain punya vocabulary stabil?Schema bisa dibuatJangan paksa generic engine
Ada banyak tenant/region/process?Configurable mengurangi duplikasiStatic UI cukup
Perlu audit policy version?Config version pentingComponent code cukup
Butuh arbitrary logic?Hati-hati, mungkin rule engineJangan taruh JS di JSON

Rule praktis:

Make variability configurable.
Keep product semantics in code.

Jangan membuat renderer terlalu generic sampai kehilangan domain.


3. Mental Model: Configurable UI sebagai Runtime Interpreter Terbatas

Configurable UI pada dasarnya adalah interpreter.

Kalimat penting:

A configurable UI renderer is a constrained interpreter. The constraint is the product.

Jika tidak dibatasi, ia menjadi bahasa pemrograman baru.


4. Core Building Blocks

Configurable workflow UI biasanya punya lima lapisan.

1. Schema
2. Registry
3. Renderer
4. State/Workflow engine
5. Command boundary

4.1 Schema

Mendeskripsikan UI/workflow dalam format data.

type WorkflowConfig = {
  id: string;
  version: number;
  title: string;
  steps: StepConfig[];
};

type StepConfig = {
  id: string;
  title: string;
  sections: SectionConfig[];
  transitions: TransitionConfig[];
  visibleWhen?: Expression;
  permission?: Capability;
};

type SectionConfig = {
  id: string;
  title: string;
  fields: FieldConfig[];
  visibleWhen?: Expression;
};

type FieldConfig = {
  id: string;
  label: string;
  component: ComponentKey;
  path: string;
  required?: boolean;
  readonlyWhen?: Expression;
  visibleWhen?: Expression;
  validation?: ValidationRule[];
};

type ComponentKey =
  | "text"
  | "textarea"
  | "select"
  | "date"
  | "money"
  | "attachment"
  | "caseReference";

Schema sebaiknya domain-specific, bukan bebas.

Bad:

{
  "component": "AnyReactComponent",
  "props": {
    "onClick": "eval('deleteCase()')"
  }
}

Better:

{
  "component": "caseReference",
  "path": "relatedCaseId",
  "label": "Related case",
  "required": true
}

4.2 Registry

Registry memetakan component key ke React component yang sudah disetujui.

type FieldRendererProps = {
  field: FieldConfig;
  value: unknown;
  error?: string;
  readonly: boolean;
  onChange: (value: unknown) => void;
};

type FieldRegistry = Record<ComponentKey, React.ComponentType<FieldRendererProps>>;

const fieldRegistry: FieldRegistry = {
  text: TextField,
  textarea: TextAreaField,
  select: SelectField,
  date: DateField,
  money: MoneyField,
  attachment: AttachmentField,
  caseReference: CaseReferenceField,
};

Registry adalah security/quality boundary:

  • hanya component yang accessible boleh masuk,
  • hanya component yang punya contract stabil boleh masuk,
  • hanya component yang memahami form state boleh masuk,
  • jangan load arbitrary component dari config mentah.

4.3 Renderer

Renderer membaca config, mengevaluasi condition, lalu render component dari registry.

function WorkflowRenderer({
  config,
  model,
  onModelChange,
}: {
  config: WorkflowConfig;
  model: Record<string, unknown>;
  onModelChange: (next: Record<string, unknown>) => void;
}) {
  return (
    <div>
      <h1>{config.title}</h1>
      {config.steps.map((step) => (
        <StepRenderer
          key={step.id}
          step={step}
          model={model}
          onModelChange={onModelChange}
        />
      ))}
    </div>
  );
}
function FieldRenderer({
  field,
  model,
  onModelChange,
}: {
  field: FieldConfig;
  model: Record<string, unknown>;
  onModelChange: (next: Record<string, unknown>) => void;
}) {
  const Component = fieldRegistry[field.component];

  if (!Component) {
    return <UnsupportedField field={field} />;
  }

  const visible = evaluateExpression(field.visibleWhen, model);
  if (!visible) return null;

  const readonly = evaluateExpression(field.readonlyWhen, model);
  const value = getByPath(model, field.path);

  return (
    <Component
      field={field}
      value={value}
      readonly={readonly}
      onChange={(nextValue) => {
        onModelChange(setByPath(model, field.path, nextValue));
      }}
    />
  );
}

Renderer harus simple. Complexity harus ada di schema validation, registry, workflow engine, dan domain hooks.


5. Expression System: Jangan Langsung Membuat Bahasa Pemrograman

Configurable UI sering butuh condition:

Show section when risk == HIGH.
Readonly when case is CLOSED.
Require field when channel == EMAIL.
Allow transition when validation passes.

Jangan menyimpan JavaScript string di database.

Bad:

{
  "visibleWhen": "model.risk === 'HIGH' && user.role === 'supervisor'"
}

Masalah:

  • security risk,
  • no type safety,
  • no static analysis,
  • impossible migration,
  • hard to audit,
  • hard to sandbox correctly.

Better: buat expression AST terbatas.

type Expression =
  | { op: "always" }
  | { op: "eq"; path: string; value: unknown }
  | { op: "neq"; path: string; value: unknown }
  | { op: "in"; path: string; values: unknown[] }
  | { op: "and"; conditions: Expression[] }
  | { op: "or"; conditions: Expression[] }
  | { op: "not"; condition: Expression }
  | { op: "hasCapability"; capability: Capability };

Evaluator:

function evaluateExpression(
  expression: Expression | undefined,
  model: Record<string, unknown>,
  capability?: (capability: Capability) => boolean
): boolean {
  if (!expression) return true;

  switch (expression.op) {
    case "always":
      return true;
    case "eq":
      return getByPath(model, expression.path) === expression.value;
    case "neq":
      return getByPath(model, expression.path) !== expression.value;
    case "in":
      return expression.values.includes(getByPath(model, expression.path));
    case "and":
      return expression.conditions.every((condition) =>
        evaluateExpression(condition, model, capability)
      );
    case "or":
      return expression.conditions.some((condition) =>
        evaluateExpression(condition, model, capability)
      );
    case "not":
      return !evaluateExpression(expression.condition, model, capability);
    case "hasCapability":
      return capability ? capability(expression.capability) : false;
    default:
      return assertNever(expression);
  }
}

Expression system harus cukup untuk produk, bukan untuk semua kemungkinan.


6. Workflow State Machine

Workflow configurable bukan hanya field renderer. Ia punya state transition.

type TransitionConfig = {
  id: string;
  label: string;
  from: string;
  to: string;
  capability?: Capability;
  guard?: Expression;
  command?: CommandConfig;
};

type CommandConfig = {
  type: "saveDraft" | "submitForReview" | "approve" | "reject" | "close";
  payloadMapping?: Record<string, string>;
};

Diagram:

Renderer hanya menampilkan action yang valid untuk current state.

function WorkflowActions({
  step,
  model,
  runCommand,
}: {
  step: StepConfig;
  model: Record<string, unknown>;
  runCommand: (command: CommandConfig) => Promise<void>;
}) {
  return (
    <div>
      {step.transitions.map((transition) => (
        <TransitionButton
          key={transition.id}
          transition={transition}
          model={model}
          runCommand={runCommand}
        />
      ))}
    </div>
  );
}
function TransitionButton({
  transition,
  model,
  runCommand,
}: {
  transition: TransitionConfig;
  model: Record<string, unknown>;
  runCommand: (command: CommandConfig) => Promise<void>;
}) {
  const decision = usePermission(transition.capability ?? "case.update");
  const guardPasses = evaluateExpression(transition.guard, model);
  const enabled = decision.allowed && guardPasses;

  return (
    <button
      type="button"
      disabled={!enabled}
      onClick={() => {
        if (!enabled || !transition.command) return;
        void runCommand(transition.command);
      }}
    >
      {transition.label}
    </button>
  );
}

Again: backend must validate transition.

Frontend transition is UX guard. Backend transition is authoritative.


7. Config Versioning

Configurable UI tanpa versioning akan rusak.

Setiap config harus punya:

type ConfigMetadata = {
  id: string;
  version: number;
  effectiveFrom?: string;
  effectiveTo?: string;
  status: "draft" | "active" | "deprecated";
  migrationFrom?: number;
};

Kenapa?

A case created under workflow v3 must remain understandable after workflow v4 ships.

Strategi:

  1. Snapshot config with record

    • record menyimpan workflowConfigId dan version.
    • aman untuk audit.
    • perlu migration path untuk old configs.
  2. Always render latest config

    • sederhana.
    • riskan untuk long-running workflows.
  3. Versioned renderer compatibility

    • renderer support N versi config.
    • lebih mahal, tetapi defensible.

Untuk regulated workflow, pilih snapshot/versioned config.


8. Validation Architecture

Validation configurable punya banyak level.

Schema validation
→ model validation
→ field validation
→ cross-field validation
→ step validation
→ transition guard
→ server validation

Jangan campur semuanya.

type ValidationRule =
  | { type: "required"; message?: string }
  | { type: "minLength"; value: number; message?: string }
  | { type: "maxLength"; value: number; message?: string }
  | { type: "pattern"; value: string; message?: string }
  | { type: "dateBefore"; path: string; message?: string }
  | { type: "custom"; id: string; message?: string };

Custom validator harus registry-based, bukan arbitrary code.

type Validator = (
  value: unknown,
  model: Record<string, unknown>,
  rule: ValidationRule
) => string | null;

const validatorRegistry: Record<string, Validator> = {
  required(value, _model, rule) {
    return value == null || value === ""
      ? rule.message ?? "This field is required."
      : null;
  },
  minLength(value, _model, rule) {
    if (typeof value !== "string") return null;
    return value.length < rule.value
      ? rule.message ?? `Must be at least ${rule.value} characters.`
      : null;
  },
};

Server validation tetap wajib.

Client validation adalah feedback cepat, bukan source of truth.


9. Data Binding and Path Safety

Naive config memakai string path:

{ "path": "applicant.address.city" }

Masalah:

  • typo baru ketahuan runtime,
  • refactor sulit,
  • hidden coupling dengan model shape.

Mitigasi:

  1. Generate path dari schema backend/shared package.
  2. Validate config terhadap model schema.
  3. Buat migration untuk rename path.
  4. Simpan config test fixture.
  5. Log unsupported path di renderer.

Runtime helper harus immutable:

function setByPath(
  model: Record<string, unknown>,
  path: string,
  value: unknown
): Record<string, unknown> {
  const keys = path.split(".");
  const next = structuredClone(model);
  let cursor: Record<string, unknown> = next;

  for (const key of keys.slice(0, -1)) {
    const child = cursor[key];
    if (typeof child !== "object" || child == null) {
      cursor[key] = {};
    }
    cursor = cursor[key] as Record<string, unknown>;
  }

  cursor[keys[keys.length - 1]] = value;
  return next;
}

Untuk performance, jangan structuredClone model besar setiap keypress di production. Gunakan state update yang lebih granular atau form library/state store yang mendukung path updates efisien.


10. Renderer State Topology

Configurable workflow UI biasanya punya banyak state.

StateOwnerCatatan
Configserver/cacheVersioned, immutable during render session
Draft form modellocal/form storeHigh-frequency updates
Server recordserver-state cacheAuthoritative resource snapshot
Current stepURL/local/workflow machineDepends on shareability
Validation errorsform state/server resultSplit client/server error source
Permission decisionsserver-provided or client capability layerMust tolerate staleness
Transition pendingmutation/action stateCommand lifecycle
UI expansion/focuslocalDo not persist unless needed

Do not put all into one Context.

Better architecture:


11. useWorkflowRuntime as Application Hook

Centralize orchestration.

function useWorkflowRuntime(workflowId: string, recordId: string) {
  const configQuery = useWorkflowConfigQuery(workflowId);
  const recordQuery = useWorkflowRecordQuery(recordId);
  const saveMutation = useSaveWorkflowMutation(recordId);
  const transitionMutation = useWorkflowTransitionMutation(recordId);

  const config = configQuery.data;
  const record = recordQuery.data;

  const [draft, setDraft] = useState(() => record?.model ?? {});

  useEffect(() => {
    if (!record) return;
    setDraft(record.model);
  }, [record?.version]);

  const runtime = useMemo(() => {
    if (!config || !record) return null;

    return createWorkflowRuntime({
      config,
      record,
      draft,
    });
  }, [config, record, draft]);

  return {
    isLoading: configQuery.isLoading || recordQuery.isLoading,
    error: configQuery.error ?? recordQuery.error,
    config,
    record,
    draft,
    setDraft,
    runtime,
    saveDraft: () => saveMutation.mutateAsync({ model: draft }),
    runTransition: (transitionId: string) =>
      transitionMutation.mutateAsync({ transitionId, model: draft }),
    isSaving: saveMutation.isPending,
    isTransitioning: transitionMutation.isPending,
  };
}

Page:

function WorkflowPage() {
  const { workflowId, recordId } = useParams();
  const workflow = useWorkflowRuntime(workflowId!, recordId!);

  if (workflow.isLoading) return <WorkflowSkeleton />;
  if (workflow.error) return <WorkflowError error={workflow.error} />;
  if (!workflow.config || !workflow.runtime) return <WorkflowUnavailable />;

  return (
    <WorkflowRenderer
      config={workflow.config}
      runtime={workflow.runtime}
      model={workflow.draft}
      onModelChange={workflow.setDraft}
      onSaveDraft={workflow.saveDraft}
      onRunTransition={workflow.runTransition}
    />
  );
}

Renderer remains mostly pure. Runtime hook handles queries/mutations/state.


12. Configurable Does Not Mean Untyped

Use TypeScript for internal contracts.

Use runtime validation for config loaded from server.

Example with a schema validator conceptually:

function parseWorkflowConfig(input: unknown): WorkflowConfig {
  const result = WorkflowConfigSchema.safeParse(input);

  if (!result.success) {
    throw new Error("Invalid workflow configuration");
  }

  return result.data;
}

Validation should happen at multiple boundaries:

configuration authoring time
configuration publish time
server API response time
frontend runtime parse time
renderer unsupported-component fallback

Never trust remote config blindly.


13. Component Registry Contract

Every configurable component must satisfy a contract.

[ ] Accepts value and onChange
[ ] Supports readonly
[ ] Supports disabled if applicable
[ ] Emits normalized value
[ ] Handles validation error
[ ] Has accessible label association
[ ] Does not own server mutation directly
[ ] Does not read global domain state implicitly
[ ] Is keyboard accessible
[ ] Has Storybook scenarios
[ ] Has regression tests

Bad field component:

function SpecialCaseField() {
  const caseId = useParams().caseId;
  const mutation = useSpecialMutation(caseId);

  return <button onClick={() => mutation.mutate()}>Do Special Thing</button>;
}

It hides route/mutation dependency inside field renderer.

Better:

function SpecialCaseField({ value, onChange, readonly, field }: FieldRendererProps) {
  return (
    <SpecialCaseSelector
      label={field.label}
      value={value as string | null}
      readonly={readonly}
      onChange={onChange}
    />
  );
}

Keep commands at workflow boundary, not inside arbitrary field components.


14. Permission Integration

Workflow config can declare permission needs.

type SectionConfig = {
  id: string;
  title: string;
  permission?: Capability;
  fields: FieldConfig[];
};

Renderer:

function SectionRenderer({ section, model }: SectionRendererProps) {
  const decision = usePermission(section.permission ?? "case.read");

  if (!decision.allowed) {
    return null;
  }

  return (
    <section>
      <h2>{section.title}</h2>
      {section.fields.map((field) => (
        <FieldRenderer key={field.id} field={field} model={model} />
      ))}
    </section>
  );
}

For field update permission:

const updateDecision = usePermission(field.updatePermission ?? "case.update");
const readonly = !updateDecision.allowed || evaluateExpression(field.readonlyWhen, model);

Backend still validates read/update/transition.


15. Extensibility Without Chaos

There are several extension mechanisms.

ExtensionSafer?Use Case
Component registry keyHighKnown UI primitives
Validator registry keyHighKnown validation rules
Command registry keyMediumKnown backend commands
Expression ASTMediumLimited conditions
Plugin packageMedium/LowInternal extension team
Arbitrary JS from configVery lowAvoid

Config should select from approved capabilities. It should not define arbitrary behavior.


16. Command Boundary

Config can describe command intent, but code should execute it.

type CommandHandler = (args: {
  command: CommandConfig;
  model: Record<string, unknown>;
  record: WorkflowRecord;
}) => Promise<void>;

const commandHandlers: Record<CommandConfig["type"], CommandHandler> = {
  async saveDraft({ model, record }) {
    await api.saveDraft(record.id, model);
  },
  async submitForReview({ model, record }) {
    await api.submitForReview(record.id, model);
  },
  async approve({ record }) {
    await api.approve(record.id);
  },
  async reject({ record }) {
    await api.reject(record.id);
  },
  async close({ record }) {
    await api.close(record.id);
  },
};

Never let config specify endpoint/method freely:

Bad:

{
  "command": {
    "method": "DELETE",
    "url": "/admin/users/123"
  }
}

Better:

{
  "command": {
    "type": "approve"
  }
}

17. Observability

Configurable UI is hard to debug without runtime visibility.

Log structured events:

type WorkflowUiEvent =
  | {
      type: "workflow_config_loaded";
      workflowId: string;
      version: number;
    }
  | {
      type: "workflow_field_changed";
      workflowId: string;
      fieldId: string;
    }
  | {
      type: "workflow_transition_clicked";
      workflowId: string;
      transitionId: string;
    }
  | {
      type: "workflow_transition_denied";
      workflowId: string;
      transitionId: string;
      reason: string;
    }
  | {
      type: "workflow_config_error";
      workflowId: string;
      errorCode: string;
    };

Do not log full model if it may contain PII, secrets, or regulated data.

Useful debug panel in non-production:

Workflow ID
Config version
Current step
Visible sections
Hidden sections and reasons
Validation errors
Available transitions
Denied transitions and reasons
Pending command

This is often more valuable than console logs.


18. Testing Strategy

Test each layer separately.

18.1 Config schema tests

invalid component key rejected
missing field path rejected
duplicate field id rejected
transition target must exist
unknown validator rejected

18.2 Expression tests

eq works
and/or/not works
unknown path returns false or configured default
capability expression calls permission service

18.3 Renderer tests

renders text field
hides section when condition false
makes field readonly when condition true
shows unsupported component fallback
preserves stable field identity

18.4 Workflow tests

only valid transitions shown
denied transition disabled
transition command called with mapped payload
server validation error mapped to field
stale transition rejection refreshes record

18.5 Golden config tests

For critical workflows, keep fixtures:

workflow-config-v1.json
workflow-config-v2.json
workflow-config-v3.json

Render them in test and Storybook.


19. Performance Considerations

Configurable renderer can become slow because:

many fields
many expressions
many permissions
many validations
large model object
frequent keystrokes
context fan-out
expensive registry components

Mitigations:

  1. Compile config once into normalized runtime.
  2. Memoize expression plans.
  3. Subscribe fields to path-level state instead of whole model.
  4. Avoid recomputing all visibility on every keystroke if graph is known.
  5. Virtualize large repeated sections if needed.
  6. Keep permission decisions cached per capability/resource/version.
  7. Profile before adding memoization everywhere.

Example normalized runtime:

type WorkflowRuntime = {
  stepsById: Map<string, StepConfig>;
  fieldsById: Map<string, FieldConfig>;
  fieldsByPath: Map<string, FieldConfig>;
  transitionsById: Map<string, TransitionConfig>;
  dependencyGraph: Map<string, string[]>;
};

Dependency graph can answer:

When model.risk changes, which fields/sections/guards need reevaluation?

20. Failure Modes

20.1 JSON programming language

Symptom:

{
  "onClick": "if (user.role === 'admin') fetch('/delete')"
}

Fix:

Use registry-based commands and limited expression AST.

20.2 Config has no version

Symptom:

Old records break after new config ships.

Fix:

Version config and store workflow version with record.

20.3 Renderer owns business logic

Symptom:

Renderer has huge switch statements for business cases.

Fix:

Move semantics to config schema, domain hook, command registry, or workflow runtime.

20.4 Component registry leaks domain dependencies

Symptom:

Field component reads route params, query cache, and mutations directly.

Fix:

Field components receive value/onChange/readonly/error. Commands live at workflow boundary.

20.5 Hidden validation mismatch

Symptom:

Hidden field still fails required validation.

Fix:

Validation must account for visibility and workflow step.

20.6 Permission mismatch

Symptom:

Transition shown enabled but backend denies.

Fix:

Treat as stale permission/resource state. Refresh record and show authorization conflict.

20.7 No authoring tool constraints

Symptom:

Config authors create invalid or inaccessible workflows.

Fix:

Validate at publish time. Provide preview, lint, and test harness.

21. Architecture Decision Checklist

Before building configurable workflow UI, answer:

[ ] What variation is truly configurable?
[ ] What variation stays in code?
[ ] Who authors config?
[ ] How is config validated before publish?
[ ] How is config versioned?
[ ] Can old records still render?
[ ] What is the component registry contract?
[ ] What is the expression language boundary?
[ ] Are commands registry-based instead of arbitrary endpoints?
[ ] Where is server authorization enforced?
[ ] How are permissions represented in config?
[ ] How are field/step/transition validations layered?
[ ] How is performance measured for large workflows?
[ ] How is config behavior tested?
[ ] How do we debug hidden fields, denied actions, and validation errors?
[ ] What is the migration plan if config shape changes?

For serious applications, use this structure:

src/
  features/
    workflow-runtime/
      api/
        workflowConfigApi.ts
        workflowRecordApi.ts
      model/
        workflowConfigSchema.ts
        workflowRuntime.ts
        expressionEvaluator.ts
        validationEngine.ts
        commandRegistry.ts
      ui/
        WorkflowRenderer.tsx
        StepRenderer.tsx
        SectionRenderer.tsx
        FieldRenderer.tsx
        WorkflowActions.tsx
      registry/
        fieldRegistry.ts
        validatorRegistry.ts
      hooks/
        useWorkflowRuntime.ts
      testing/
        workflowFixtures.ts
        renderWorkflow.tsx

Dependency direction:

Page
→ useWorkflowRuntime
→ workflow model/api/registry
→ renderer
→ approved field components

Renderer should not import page-specific hooks.

Field components should not import workflow mutation commands.

Command execution should happen at workflow runtime boundary.


23. Key Takeaways

Configurable workflow UI is powerful when it models real product variability.

It becomes dangerous when it tries to replace programming with untyped configuration.

The correct mental model:

Config describes approved product variation.
Registry supplies approved behavior.
Renderer interprets config predictably.
Workflow machine controls transitions.
Command boundary talks to backend.
Backend remains authoritative.

Final rule:

Do not build a generic UI engine. Build a constrained workflow interpreter for a specific domain.


References

Lesson Recap

You just completed lesson 102 in final stretch. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.