State machine discipline, saga compensation, event-sourced persistence, workflow orchestration. Use when state transitions, workflow steps, saga, compensation, event sourcing, or state machine testing.
Scanned 9/2/2026
Install to Claude Code
npx -y skills add kookr-ai/kookr --skill state-machine-workflow-patterns --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of State Machine Workflow Patterns?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/kookr-ai-state-machine-workflow-patterns)More formats (shields.io, HTML) on the badges page.
---
name: state-machine-workflow-patterns
description: State machine discipline, saga compensation, event-sourced persistence, workflow orchestration. Use when state transitions, workflow steps, saga, compensation, event sourcing, or state machine testing.
keywords: state machine, FSM, XState, state transition, saga, compensation, event sourcing, workflow orchestration, choreography, transition guard, append-only, replay, discriminated union, optimistic locking, heartbeat, timeout, model-based testing
related: error-handling-patterns, async-flow-control, process-lifecycle-patterns, event-driven-messaging-patterns, domain-driven-design
---
# State Machine & Workflow Patterns
Rules for building state machines and multi-step workflows that survive crashes, prevent invalid transitions, and compensate on failure.
**Research:** `docs/deepresearch/reports/State Machine and Workflow Patterns.md`
## Non-Negotiable Rules
| # | Rule | Violation Example | Correct Pattern |
|---|------|-------------------|-----------------|
| 1 | **Explicit machine definition** | Ad-hoc `if (status === 'x')` scattered in controllers | Single machine definition (XState, map, or guard table) |
| 2 | **Reject invalid transitions at state layer** | `pending` -> `completed` bypass (missing intermediate) | Machine only accepts defined transitions |
| 3 | **Discriminated unions for state types** | `{ status?: string; startedAt?: Date; completedAt?: Date }` | `{ status: 'completed'; startedAt: Date; completedAt: Date }` |
| 4 | **Persist transition history** | Single `status` column, no history | `state_transitions` table or event log |
| 5 | **Saga compensation on multi-step** | `chargePayment(); createOrder()` with no rollback | Explicit compensation list, reverse-order execution |
| 6 | **Timeout + heartbeat on every activity** | Workflow step with no timeout -> zombie | `startToCloseTimeout` + `heartbeatTimeout` on activities |
| 7 | **Optimistic lock or single-writer** | Read-modify-write on state without version guard | `WHERE version = $v` or per-entity queue |
| 8 | **Test every transition + every invalid transition** | Only test happy path | Generate tests from machine definition |
| 9 | **Declarative workflows for >5 steps** | Imperative glue code across services | Temporal, Step Functions, or declarative definition |
| 10 | **State metrics per status** | No visibility into stuck workflows | `gauge:workflows_in_state{state="x"}` for monitoring |
## State Transition Validation
```typescript
// WRONG: Ad-hoc status checks scattered across controllers
async function shipOrder(orderId: string) {
const order = await db.orders.findUnique({ where: { id: orderId } });
if (order.status === 'completed') throw new Error('Already shipped');
// No check: pending -> completed bypasses 'processing'
await db.orders.update({ data: { status: 'completed' } });
}
// CORRECT: Single machine definition, transitions validated centrally
const ORDER_TRANSITIONS: Record<string, Record<string, string>> = {
pending: { PROCESS: 'processing' },
processing: { SHIP: 'completed', CANCEL: 'cancelled' },
completed: {}, // final state
cancelled: {}, // final state
};
function transition(current: string, event: string): string {
const next = ORDER_TRANSITIONS[current]?.[event];
if (!next) throw new Error(`Invalid transition: ${current} + ${event}`);
return next;
}
// Usage: validate BEFORE any DB write
const nextStatus = transition(order.status, 'SHIP');
await db.orders.update({ data: { status: nextStatus, shippedAt: new Date() } });
```
**Rule:** Never scatter `if (status === ...)` checks across multiple files. Define the machine once. All status changes go through it.
## Invalid State Prevention Through Types
```typescript
// WRONG: Bag of optionals - compiles, crashes at runtime
type Order = {
status?: 'pending' | 'processing' | 'completed';
startedAt?: Date;
completedAt?: Date;
};
// order.completedAt accessed when status is 'pending' -> undefined
// CORRECT: Discriminated unions make impossible states unrepresentable
type PendingOrder = { status: 'pending' };
type ProcessingOrder = { status: 'processing'; startedAt: Date };
type CompletedOrder = { status: 'completed'; startedAt: Date; completedAt: Date };
type CancelledOrder = { status: 'cancelled'; cancelledAt: Date; reason: string };
type OrderState = PendingOrder | ProcessingOrder | CompletedOrder | CancelledOrder;
// Compiler enforces: completedAt only accessible when status is 'completed'
function getCompletionTime(order: OrderState): Date | null {
if (order.status === 'completed') return order.completedAt; // type-safe
return null;
}
```
**Rule:** Every multi-state entity must use discriminated unions. The compiler must reject access to state-specific fields in wrong states.
## State Persistence & Recovery
```typescript
// WRONG: Single status column, crash loses in-flight work
await db.orders.update({ data: { status: 'processing' } });
// Pod crashes here -> no record of what happened
// CORRECT: Append-only transition log + derived current state
await db.execute(sql`
INSERT INTO state_transitions (entity_id, entity_type, from_state, to_state, event, metadata, created_at)
VALUES (${orderId}, 'order', ${currentState}, ${nextState}, ${event}, ${sql.json(meta)}, NOW())
`);
await db.execute(sql`
UPDATE orders SET status = ${nextState}, updated_at = NOW(), version = version + 1
WHERE id = ${orderId} AND version = ${currentVersion}
`);
// Recovery: replay transitions to rebuild current state
const transitions = await db.execute(sql`
SELECT * FROM state_transitions
WHERE entity_id = ${orderId} AND entity_type = 'order'
ORDER BY created_at ASC
`);
```
**Schema:**
```sql
CREATE TABLE state_transitions (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
entity_id UUID NOT NULL,
entity_type TEXT NOT NULL,
from_state TEXT NOT NULL,
to_state TEXT NOT NULL,
event TEXT NOT NULL,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_transitions_entity ON state_transitions (entity_id, entity_type, created_at);
```
**Rule:** For any entity that lives longer than a single request, persist every state transition. Current state = derived from transition history. This enables replay, audit, and crash recovery.
## Saga Pattern (Distributed Compensation)
```typescript
// WRONG: Sequential calls with no compensation -> partial failure leaves inconsistent state
await chargePayment(paymentId);
await createOrder(orderId); // Fails -> money taken, no order
await reserveInventory(itemId); // Never reached
// CORRECT: Explicit compensations, reverse-order execution on failure
class Saga {
private compensations: Array<() => Promise<void>> = [];
addCompensation(fn: () => Promise<void>): void {
this.compensations.push(fn);
}
async compensate(): Promise<void> {
// Execute compensations in reverse order (LIFO)
for (const comp of [...this.compensations].reverse()) {
try {
await comp();
} catch (err) {
logger.error({ err }, 'Compensation failed - manual intervention needed');
// Log but continue: best-effort compensation
}
}
}
}
// Usage
const saga = new Saga();
const payment = await chargePayment(paymentId);
saga.addCompensation(() => refundPayment(payment.transactionId));
try {
const order = await createOrder(orderId);
saga.addCompensation(() => cancelOrder(order.id));
await reserveInventory(itemId);
saga.addCompensation(() => releaseInventory(itemId));
} catch (err) {
await saga.compensate(); // Undo everything in reverse
throw new Error('Order workflow failed', { cause: err });
}
```
**Rule:** Every multi-service operation must register a compensation for each completed step. On failure, compensations run in reverse order. Log compensation failures but continue (best-effort cleanup).
## Timeouts & Heartbeats
```typescript
// WRONG: No timeout -> zombie workflow step runs forever
await processLargeDataset(datasetId);
// CORRECT: Every activity has timeout + heartbeat
async function processWithTimeout(
datasetId: string,
signal?: AbortSignal,
): Promise<void> {
const timeout = AbortSignal.timeout(5 * 60 * 1000); // 5 min max
const combined = AbortSignal.any([timeout, ...(signal ? [signal] : [])]);
for await (const batch of getBatches(datasetId)) {
combined.throwIfAborted(); // Heartbeat check between batches
await processBatch(batch);
}
}
// In workflow orchestrator: track last heartbeat
async function monitorActivity(activityId: string, heartbeatInterval: number) {
const activity = await getActivity(activityId);
const elapsed = Date.now() - activity.lastHeartbeat.getTime();
if (elapsed > heartbeatInterval * 3) {
logger.warn({ activityId, elapsed }, 'Activity missed heartbeat');
await timeoutActivity(activityId);
}
}
```
**Rule:** Every workflow activity must have `startToCloseTimeout` and `heartbeatTimeout`. Activities that process data in loops must check abort/heartbeat between iterations.
## Workflow Orchestration vs Choreography
| Aspect | Orchestration | Choreography |
|--------|--------------|--------------|
| Control flow | Central coordinator | Events between services |
| Visibility | Full workflow state in one place | Scattered across services |
| Debugging | Easy: query orchestrator state | Hard: correlate events across services |
| Coupling | Services depend on orchestrator | Services depend on event contracts |
| Compensation | Natural: orchestrator runs compensations | Hard: who owns rollback? |
| Best for | Workflows >5 steps, need audit/visibility | Loosely coupled, simple fanout |
**Rule:** For workflows that need visibility, audit, or compensation, use orchestration (Temporal, Step Functions, or custom orchestrator). Use choreography only for simple event fanout where partial failure is acceptable.
## State Machine Testing
```typescript
// WRONG: Only test happy path
test('order completes', () => {
expect(transition('processing', 'SHIP')).toBe('completed');
});
// CORRECT: Test every valid transition AND every invalid transition
describe('order state machine', () => {
const VALID_TRANSITIONS = [
['pending', 'PROCESS', 'processing'],
['processing', 'SHIP', 'completed'],
['processing', 'CANCEL', 'cancelled'],
] as const;
const INVALID_TRANSITIONS = [
['pending', 'SHIP'], // Can't ship without processing
['pending', 'CANCEL'], // Must process before cancel (if that's the rule)
['completed', 'PROCESS'], // Final states accept nothing
['completed', 'SHIP'],
['cancelled', 'PROCESS'],
] as const;
test.each(VALID_TRANSITIONS)('%s + %s -> %s', (from, event, to) => {
expect(transition(from, event)).toBe(to);
});
test.each(INVALID_TRANSITIONS)('%s + %s -> throws', (from, event) => {
expect(() => transition(from, event)).toThrow('Invalid transition');
});
});
```
**Rule:** When defining a state machine, immediately generate tests covering every valid transition and every invalid transition. Use `test.each` with the transition table. If you add a state, add its valid/invalid transition tests in the same commit.
## Concurrent State Mutation Guard
```typescript
// WRONG: Read-modify-write with no guard -> lost updates
const order = await db.orders.findUnique({ where: { id: orderId } });
order.status = 'completed';
await db.orders.update({ data: { status: order.status } });
// CORRECT: Optimistic locking with version column
const result = await db.execute(sql`
UPDATE orders
SET status = ${nextStatus}, version = version + 1, updated_at = NOW()
WHERE id = ${orderId} AND version = ${expectedVersion}
RETURNING *
`);
if (result.length === 0) {
throw new Error('Concurrent modification detected - retry');
}
// CORRECT: Single-writer per entity (queue + dedicated processor)
await redis.xadd(`order:${orderId}:events`, '*', 'event', JSON.stringify({
type: 'SHIP',
timestamp: Date.now(),
}));
// Single consumer per stream key ensures serial processing
```
**Rule:** Never do read-modify-write on state without either optimistic locking (`WHERE version = $v`), pessimistic locking (`FOR UPDATE`), or single-writer guarantee (per-entity queue). See race-conditions-atomicity for DB-level patterns.
## Common Anti-Patterns Checklist
Before submitting workflow/state-machine code, verify:
- [ ] No raw string/enum status without a machine definition
- [ ] No bag-of-optional-properties for multi-state entities (use discriminated unions)
- [ ] No single `status` column without transition history for long-lived entities
- [ ] No multi-service calls without compensation (saga pattern)
- [ ] No workflow activity without timeout + heartbeat
- [ ] No read-modify-write on state without concurrency guard
- [ ] No state machine without tests for every valid AND invalid transition
- [ ] No imperative glue code for workflows >5 steps (use declarative orchestration)
- [ ] No workflow without state metrics/observability
## See Also
[[error-handling-patterns]] - Error classification, discriminated union results, cause chaining
race-conditions-atomicity - Optimistic locking, SKIP LOCKED, distributed locks, idempotency
resilience-patterns - Circuit breakers, TaskReaper, auto-healing, multi-layer recovery
[[async-flow-control]] - AbortController cancellation, Promise combinators, graceful shutdown
[[event-driven-messaging-patterns]] - Outbox pattern, consumer idempotency, DLQ
[[domain-driven-design]] - Aggregate boundaries, value objects, domain events
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!