03 State machines 6 chapters

Theme 02 · Domain-Driven / Functional-Core Minded

State machines

Explanation

State machines

Plain Human Explanation

A workflow should only move through states the product actually allows. A queued job should not jump from “waiting” straight to “succeeded” without running, and a shipped order should not become “draft” again. A state machine makes legal moves visible.

Technical Explanation

Model each workflow state as a distinct variant and expose transition functions for allowed moves. The transition function accepts a specific current state and returns the next state or a typed failure. This keeps random string assignment from becoming the way the system changes important status.

Why It Matters

  • User impact: workflows behave predictably and do not skip important steps.
  • Product behavior: allowed transitions are documented in code, not scattered across if statements.
  • Risk: direct status updates can create states the rest of the system is not prepared to handle.
  • Decision point: use this when status changes drive access, fulfillment, retries, billing, or notifications.

The Core Move

Represent states and transitions together. Make it easy to perform legal moves and hard to invent new ones by accident.

Small Example

State machines: Small Example

Bad TypeScript Example

type Job = {
  id: string;
  status: string;
};

export function markJobDone(job: Job) {
  job.status = "done";
  return job;
}
type Job = {
  id: string;
  status: string;
};

export function markJobDone(job: Job) {
  job.status = "done";

  return job;
}

Good TypeScript Example

type WaitingJob = {
  kind: "waiting";
  id: string;
};
type RunningJob = {
  kind: "running";
  id: string;
  startedAt: Date;
};
type DoneJob = {
  kind: "done";
  id: string;
  finishedAt: Date;
};

type Job = WaitingJob | RunningJob | DoneJob;

export function startJob(job: WaitingJob, now: Date): RunningJob {
  return { kind: "running", id: job.id, startedAt: now };
}

export function finishJob(job: RunningJob, now: Date): DoneJob {
  return { kind: "done", id: job.id, finishedAt: now };
}
type WaitingJob = {
  kind: "waiting";
  id: string;
};
type RunningJob = {
  kind: "running";
  id: string;
  startedAt: Date;
};
type DoneJob = {
  kind: "done";
  id: string;
  finishedAt: Date;
};

type Job =
  | WaitingJob
  | RunningJob
  | DoneJob;

export function startJob(
  job: WaitingJob,
  now: Date,
): RunningJob {
  return {
    kind: "running",
    id: job.id,
    startedAt: now,
  };
}

export function finishJob(
  job: RunningJob,
  now: Date,
): DoneJob {
  return {
    kind: "done",
    id: job.id,
    finishedAt: now,
  };
}

What Changed

  • The bad version lets any caller write any status string at any time.
  • The good version only exposes legal transitions.
  • A job must be running before it can finish.

Realistic Example

State machines: Realistic Example

This example uses order fulfillment, where skipping a state can cause support and shipping problems.

Bad TypeScript Example

type Order = {
  id: string;
  status: "paid" | "packed" | "shipped" | "delivered" | "cancelled";
  trackingNumber?: string;
};

export function shipOrder(order: Order, trackingNumber: string) {
  return { ...order, status: "shipped", trackingNumber };
}
type Order = {
  id: string;
  status:
    | "paid"
    | "packed"
    | "shipped"
    | "delivered"
    | "cancelled";
  trackingNumber?: string;
};

export function shipOrder(
  order: Order,
  trackingNumber: string,
) {
  return {
    ...order,
    status: "shipped",
    trackingNumber,
  };
}

Good TypeScript Example

type PaidOrder = {
  kind: "paid";
  id: string;
};
type PackedOrder = {
  kind: "packed";
  id: string;
  packedAt: Date;
};
type ShippedOrder = {
  kind: "shipped";
  id: string;
  trackingNumber: string;
};

function packOrder(order: PaidOrder, now: Date): PackedOrder {
  return { kind: "packed", id: order.id, packedAt: now };
}

function shipOrder(order: PackedOrder, trackingNumber: string): ShippedOrder {
  return { kind: "shipped", id: order.id, trackingNumber };
}
type PaidOrder = {
  kind: "paid";
  id: string;
};
type PackedOrder = {
  kind: "packed";
  id: string;
  packedAt: Date;
};
type ShippedOrder = {
  kind: "shipped";
  id: string;
  trackingNumber: string;
};

function packOrder(
  order: PaidOrder,
  now: Date,
): PackedOrder {
  return {
    kind: "packed",
    id: order.id,
    packedAt: now,
  };
}

function shipOrder(
  order: PackedOrder,
  trackingNumber: string,
): ShippedOrder {
  return {
    kind: "shipped",
    id: order.id,
    trackingNumber,
  };
}

What Changed

  • The bad version can ship an order that was never packed.
  • The good version makes packing a required step before shipping.
  • Tracking data only appears on shipped orders, where it has meaning.

System Example

State machines: System Example

At system scale, state machines keep API commands, workers, and retries from inventing conflicting workflow moves.

Larger System-Level Bad TypeScript Example

type ImportRecord = {
  id: string;
  status: string;
  attempts: number;
};
type ImportStore = {
  find(importId: string): Promise<ImportRecord>;
  save(record: ImportRecord): Promise<void>;
};

export async function retryImport(importId: string, store: ImportStore) {
  const record = await store.find(importId);
  record.status = "running";
  record.attempts = record.attempts + 1;
  await store.save(record);
}

export async function completeImport(importId: string, store: ImportStore) {
  const record = await store.find(importId);
  record.status = "complete";
  await store.save(record);
}
type ImportRecord = {
  id: string;
  status: string;
  attempts: number;
};
type ImportStore = {
  find(
    importId: string,
  ): Promise<ImportRecord>;
  save(record: ImportRecord): Promise<void>;
};

export async function retryImport(
  importId: string,
  store: ImportStore,
) {
  const record = await store.find(importId);

  record.status = "running";

  record.attempts = record.attempts + 1;

  await store.save(record);
}

export async function completeImport(
  importId: string,
  store: ImportStore,
) {
  const record = await store.find(importId);

  record.status = "complete";

  await store.save(record);
}

Larger System-Level Good TypeScript Example

type PendingImport = {
  kind: "pending";
  id: string;
  attempts: number;
};
type RunningImport = {
  kind: "running";
  id: string;
  attempts: number;
  startedAt: Date;
};
type CompleteImport = {
  kind: "complete";
  id: string;
  completedAt: Date;
};
type FailedImport = {
  kind: "failed";
  id: string;
  reason: string;
};
type ImportRecord = PendingImport | RunningImport | CompleteImport | FailedImport;
type ImportStore = {
  findPending(importId: string): Promise<PendingImport>;
  findRunning(importId: string): Promise<RunningImport>;
  save(record: ImportRecord): Promise<void>;
};

function startImport(record: PendingImport, now: Date): RunningImport | FailedImport {
  if (record.attempts >= 3) return { kind: "failed", id: record.id, reason: "too-many-attempts" };
  return { kind: "running", id: record.id, attempts: record.attempts + 1, startedAt: now };
}

function completeImport(record: RunningImport, now: Date): CompleteImport {
  return { kind: "complete", id: record.id, completedAt: now };
}

export async function retryImport(importId: string, store: ImportStore, now: Date) {
  const record = await store.findPending(importId);
  await store.save(startImport(record, now));
}

export async function completeRunningImport(importId: string, store: ImportStore, now: Date) {
  const record = await store.findRunning(importId);
  await store.save(completeImport(record, now));
}
type PendingImport = {
  kind: "pending";
  id: string;
  attempts: number;
};
type RunningImport = {
  kind: "running";
  id: string;
  attempts: number;
  startedAt: Date;
};
type CompleteImport = {
  kind: "complete";
  id: string;
  completedAt: Date;
};
type FailedImport = {
  kind: "failed";
  id: string;
  reason: string;
};
type ImportRecord =
  | PendingImport
  | RunningImport
  | CompleteImport
  | FailedImport;
type ImportStore = {
  findPending(
    importId: string,
  ): Promise<PendingImport>;
  findRunning(
    importId: string,
  ): Promise<RunningImport>;
  save(record: ImportRecord): Promise<void>;
};

function startImport(
  record: PendingImport,
  now: Date,
): RunningImport | FailedImport {
  if (record.attempts >= 3)
    return {
      kind: "failed",
      id: record.id,
      reason: "too-many-attempts",
    };

  return {
    kind: "running",
    id: record.id,
    attempts: record.attempts + 1,
    startedAt: now,
  };
}

function completeImport(
  record: RunningImport,
  now: Date,
): CompleteImport {
  return {
    kind: "complete",
    id: record.id,
    completedAt: now,
  };
}

export async function retryImport(
  importId: string,
  store: ImportStore,
  now: Date,
) {
  const record =
    await store.findPending(importId);

  await store.save(
    startImport(record, now),
  );
}

export async function completeRunningImport(
  importId: string,
  store: ImportStore,
  now: Date,
) {
  const record =
    await store.findRunning(importId);

  await store.save(
    completeImport(record, now),
  );
}

What Changed

  • The bad version edits status strings directly and can complete an import that was never running.
  • The good version exposes and uses only valid transition functions.
  • Retry limits and timestamps live with the workflow rule instead of being scattered across workers.

When To Use It

State machines: When To Use It

Use This When

  • A status field controls what actions are allowed next.
  • Some transitions require data, such as timestamps, reasons, attempt counts, or tracking numbers.
  • Invalid transitions would create support, billing, fulfillment, or retry problems.

Avoid This When

  • The state is simple display-only text.
  • Every state can move to every other state without rules.
  • A full state machine would hide a single obvious boolean.

Tradeoffs

State machines add types and transition functions. The benefit is that workflow rules become visible and testable. Keep the state list small and tied to real product behavior.

  • Illegal states unrepresentable
  • Typed errors
  • Functional core / imperative shell

Practice Prompt

State machines: Practice Prompt

Beginner Exercise

Find a status field that changes over time. Write the allowed transitions in plain English.

Intermediate Exercise

Model two or three states as separate TypeScript variants and write one transition function.

Stretch Exercise

Update one caller so it uses the transition function instead of assigning a status string directly.

Reflection Question

Which transition did the new model prevent, and would that transition have caused a real product problem?

Suggest an edit

Leave a private editorial note. This creates a GitHub issue for this curriculum page.