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
ifstatements. - 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.
Related Concepts
- 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.