State Machines for Complex Business Workflows
How to model complex business workflows as finite state machines in TypeScript, with type-safe transitions, guards, and side effects managed by Alfred.
Many workflow engines model processes as a sequence of steps: do A, then B, then C. This works well for linear processes, but real business workflows are rarely linear. An order can be placed, partially fulfilled, backordered, cancelled, refunded, or disputed. A loan application can be submitted, under review, approved, conditionally approved, denied, or withdrawn. The transitions between these states depend on business rules, external events, and human decisions.
State machines provide a formal model for these complex workflows. Instead of thinking about what to do next, you think about what states a process can be in and what events cause transitions between those states. This shift in perspective makes complex workflows easier to reason about, test, and visualize.
Alfred includes a full state machine engine designed specifically for business workflows, with type-safe state definitions, transition guards, and side effects.
Defining States and Transitions
A state machine starts with a clear enumeration of all possible states and the events that trigger transitions between them. In TypeScript, we can use union types to make this definition exhaustive and type-safe.
import { StateMachine, StateConfig, TransitionConfig } from '@alfred/state-machine';
// All possible states for an order
type OrderState =
| 'draft'
| 'submitted'
| 'payment_pending'
| 'payment_confirmed'
| 'fulfillment_pending'
| 'partially_shipped'
| 'shipped'
| 'delivered'
| 'cancelled'
| 'refund_pending'
| 'refunded';
// All possible events that can trigger state transitions
type OrderEvent =
| { type: 'SUBMIT' }
| { type: 'PAYMENT_RECEIVED'; transactionId: string }
| { type: 'PAYMENT_FAILED'; reason: string }
| { type: 'ITEMS_SHIPPED'; shipmentId: string; itemIds: string[] }
| { type: 'ALL_ITEMS_SHIPPED' }
| { type: 'DELIVERY_CONFIRMED' }
| { type: 'CANCEL_REQUESTED'; reason: string }
| { type: 'REFUND_INITIATED'; amount: number }
| { type: 'REFUND_COMPLETED' };
// The context that travels with the state machine
interface OrderContext {
orderId: string;
customerId: string;
items: Array<{ id: string; sku: string; quantity: number; price: number }>;
shippedItemIds: string[];
paymentTransactionId?: string;
cancellationReason?: string;
refundAmount?: number;
}
const orderStateMachine = new StateMachine<OrderState, OrderEvent, OrderContext>({
id: 'order-lifecycle',
initial: 'draft',
context: {
orderId: '',
customerId: '',
items: [],
shippedItemIds: [],
},
states: {
draft: {
on: {
SUBMIT: { target: 'submitted' },
},
},
submitted: {
on: {
PAYMENT_RECEIVED: { target: 'payment_confirmed' },
PAYMENT_FAILED: { target: 'payment_pending' },
CANCEL_REQUESTED: { target: 'cancelled' },
},
entry: async (ctx) => {
await orderService.notifySubmitted(ctx.orderId);
await paymentService.requestPayment(ctx.orderId, calculateTotal(ctx.items));
},
},
payment_pending: {
on: {
PAYMENT_RECEIVED: { target: 'payment_confirmed' },
CANCEL_REQUESTED: { target: 'cancelled' },
},
after: {
// Auto-cancel if payment not received within 24 hours
86400000: { target: 'cancelled' },
},
},
payment_confirmed: {
on: {
ITEMS_SHIPPED: { target: 'partially_shipped' },
ALL_ITEMS_SHIPPED: { target: 'shipped' },
CANCEL_REQUESTED: { target: 'refund_pending' },
},
entry: async (ctx, event) => {
if (event.type === 'PAYMENT_RECEIVED') {
ctx.paymentTransactionId = event.transactionId;
}
await fulfillmentService.startFulfillment(ctx.orderId, ctx.items);
},
},
partially_shipped: {
on: {
ITEMS_SHIPPED: [
{
target: 'shipped',
guard: (ctx) => ctx.shippedItemIds.length === ctx.items.length,
},
{
target: 'partially_shipped',
},
],
CANCEL_REQUESTED: { target: 'refund_pending' },
},
entry: async (ctx, event) => {
if (event.type === 'ITEMS_SHIPPED') {
ctx.shippedItemIds = [...ctx.shippedItemIds, ...event.itemIds];
}
},
},
shipped: {
on: {
DELIVERY_CONFIRMED: { target: 'delivered' },
},
entry: async (ctx) => {
await notificationService.sendShippedNotification(ctx.orderId, ctx.customerId);
},
},
delivered: {
on: {
REFUND_INITIATED: { target: 'refund_pending' },
},
type: 'final',
},
cancelled: {
type: 'final',
entry: async (ctx, event) => {
if (event.type === 'CANCEL_REQUESTED') {
ctx.cancellationReason = event.reason;
}
await orderService.notifyCancelled(ctx.orderId, ctx.cancellationReason);
},
},
refund_pending: {
on: {
REFUND_COMPLETED: { target: 'refunded' },
},
entry: async (ctx) => {
await paymentService.initiateRefund(ctx.paymentTransactionId!);
},
},
refunded: {
type: 'final',
entry: async (ctx) => {
await notificationService.sendRefundConfirmation(ctx.orderId, ctx.customerId);
},
},
},
});This definition captures the complete lifecycle of an order in one place. Every possible state is explicit. Every possible transition is explicit. If you try to send a DELIVERY_CONFIRMED event to an order in the draft state, the state machine rejects it because no such transition is defined.
Transition Guards and Conditional Logic
Transition guards are predicates that must evaluate to true for a transition to fire. They allow you to encode complex business rules directly into the state machine definition.
import { StateMachine, Guard } from '@alfred/state-machine';
type LoanState =
| 'application_received'
| 'credit_check'
| 'manual_review'
| 'auto_approved'
| 'approved'
| 'denied'
| 'disbursed';
type LoanEvent =
| { type: 'START_REVIEW' }
| { type: 'CREDIT_CHECK_COMPLETE'; score: number; riskLevel: string }
| { type: 'REVIEWER_DECISION'; approved: boolean; notes: string }
| { type: 'DISBURSE' };
interface LoanContext {
applicationId: string;
applicantId: string;
requestedAmount: number;
creditScore?: number;
riskLevel?: string;
reviewerNotes?: string;
}
// Named guards for reusability and testing
const guards = {
isLowRisk: (ctx: LoanContext): boolean =>
(ctx.creditScore ?? 0) >= 750 && ctx.requestedAmount <= 50000,
isMediumRisk: (ctx: LoanContext): boolean =>
(ctx.creditScore ?? 0) >= 600 && (ctx.creditScore ?? 0) < 750,
isHighRisk: (ctx: LoanContext): boolean =>
(ctx.creditScore ?? 0) < 600,
isApprovedByReviewer: (ctx: LoanContext, event: LoanEvent): boolean =>
event.type === 'REVIEWER_DECISION' && event.approved,
isDeniedByReviewer: (ctx: LoanContext, event: LoanEvent): boolean =>
event.type === 'REVIEWER_DECISION' && !event.approved,
};
const loanStateMachine = new StateMachine<LoanState, LoanEvent, LoanContext>({
id: 'loan-application',
initial: 'application_received',
context: {
applicationId: '',
applicantId: '',
requestedAmount: 0,
},
states: {
application_received: {
on: {
START_REVIEW: { target: 'credit_check' },
},
},
credit_check: {
on: {
CREDIT_CHECK_COMPLETE: [
{
target: 'auto_approved',
guard: guards.isLowRisk,
actions: [(ctx, event) => {
if (event.type === 'CREDIT_CHECK_COMPLETE') {
ctx.creditScore = event.score;
ctx.riskLevel = event.riskLevel;
}
}],
},
{
target: 'manual_review',
guard: guards.isMediumRisk,
actions: [(ctx, event) => {
if (event.type === 'CREDIT_CHECK_COMPLETE') {
ctx.creditScore = event.score;
ctx.riskLevel = event.riskLevel;
}
}],
},
{
target: 'denied',
guard: guards.isHighRisk,
actions: [(ctx, event) => {
if (event.type === 'CREDIT_CHECK_COMPLETE') {
ctx.creditScore = event.score;
ctx.riskLevel = event.riskLevel;
}
}],
},
],
},
entry: async (ctx) => {
const result = await creditBureau.check(ctx.applicantId);
await loanStateMachine.send({
type: 'CREDIT_CHECK_COMPLETE',
score: result.score,
riskLevel: result.riskLevel,
});
},
},
manual_review: {
on: {
REVIEWER_DECISION: [
{ target: 'approved', guard: guards.isApprovedByReviewer },
{ target: 'denied', guard: guards.isDeniedByReviewer },
],
},
entry: async (ctx) => {
await reviewQueue.enqueue({
applicationId: ctx.applicationId,
creditScore: ctx.creditScore,
requestedAmount: ctx.requestedAmount,
});
},
},
auto_approved: {
on: {
DISBURSE: { target: 'disbursed' },
},
entry: async (ctx) => {
await notificationService.sendApprovalNotice(ctx.applicantId);
},
},
approved: {
on: {
DISBURSE: { target: 'disbursed' },
},
entry: async (ctx) => {
await notificationService.sendApprovalNotice(ctx.applicantId);
},
},
denied: {
type: 'final',
entry: async (ctx) => {
await notificationService.sendDenialNotice(ctx.applicantId);
},
},
disbursed: {
type: 'final',
entry: async (ctx) => {
await bankingService.transfer(ctx.applicantId, ctx.requestedAmount);
},
},
},
});When the CREDIT_CHECK_COMPLETE event arrives, the state machine evaluates the guards in order and takes the first matching transition. This replaces a chain of if-else statements with a declarative, testable set of rules.
Named guards are important for two reasons. First, they can be unit tested independently. Second, they appear in state machine visualizations and logs, making it easy to understand why a particular transition was taken.
Hierarchical and Parallel States
Real-world workflows often have states that contain sub-states. An order in the fulfillment state might have sub-states for picking, packing, and shipping. Hierarchical state machines model this naturally.
import { StateMachine, HierarchicalState } from '@alfred/state-machine';
type SubscriptionState =
| 'trial'
| 'active'
| 'active.payment_current'
| 'active.payment_overdue'
| 'active.payment_grace_period'
| 'suspended'
| 'cancelled';
type SubscriptionEvent =
| { type: 'TRIAL_EXPIRED' }
| { type: 'PAYMENT_RECEIVED' }
| { type: 'PAYMENT_FAILED' }
| { type: 'GRACE_PERIOD_EXPIRED' }
| { type: 'ACCOUNT_REACTIVATED' }
| { type: 'CANCEL_SUBSCRIPTION' };
interface SubscriptionContext {
subscriptionId: string;
userId: string;
plan: string;
failedPaymentCount: number;
lastPaymentDate?: Date;
}
const subscriptionMachine = new StateMachine<
SubscriptionState,
SubscriptionEvent,
SubscriptionContext
>({
id: 'subscription-lifecycle',
initial: 'trial',
context: {
subscriptionId: '',
userId: '',
plan: 'starter',
failedPaymentCount: 0,
},
states: {
trial: {
on: {
TRIAL_EXPIRED: { target: 'active' },
CANCEL_SUBSCRIPTION: { target: 'cancelled' },
},
after: {
1209600000: { target: 'active' }, // 14 days
},
},
active: {
initial: 'payment_current',
on: {
CANCEL_SUBSCRIPTION: { target: 'cancelled' },
},
states: {
payment_current: {
on: {
PAYMENT_FAILED: { target: 'payment_grace_period' },
},
},
payment_grace_period: {
on: {
PAYMENT_RECEIVED: { target: 'payment_current' },
GRACE_PERIOD_EXPIRED: { target: 'payment_overdue' },
},
after: {
259200000: { target: 'payment_overdue' }, // 3 days
},
entry: async (ctx) => {
await notificationService.sendPaymentReminder(ctx.userId);
},
},
payment_overdue: {
on: {
PAYMENT_RECEIVED: { target: 'payment_current' },
},
entry: async (ctx) => {
ctx.failedPaymentCount += 1;
if (ctx.failedPaymentCount >= 3) {
await subscriptionMachine.send({ type: 'CANCEL_SUBSCRIPTION' });
}
await notificationService.sendOverdueNotice(ctx.userId);
},
},
},
},
suspended: {
on: {
ACCOUNT_REACTIVATED: { target: 'active' },
CANCEL_SUBSCRIPTION: { target: 'cancelled' },
},
},
cancelled: {
type: 'final',
entry: async (ctx) => {
await billingService.cancelSubscription(ctx.subscriptionId);
await notificationService.sendCancellationConfirmation(ctx.userId);
},
},
},
});The active state contains sub-states for payment status. The CANCEL_SUBSCRIPTION event defined on the active state is available in all sub-states, which means you can cancel a subscription regardless of whether the payment is current, in a grace period, or overdue. This eliminates the need to define the cancel transition in every sub-state.
Persistence and Recovery
State machines in Alfred are persisted to durable storage, allowing them to survive process restarts and to handle long-running workflows that span days or weeks.
import { StateMachineEngine, PostgresStateStore } from '@alfred/state-machine';
const stateStore = new PostgresStateStore({
connectionString: process.env.DATABASE_URL,
tableName: 'state_machine_instances',
});
const engine = new StateMachineEngine({
store: stateStore,
machines: {
'order-lifecycle': orderStateMachine,
'loan-application': loanStateMachine,
'subscription-lifecycle': subscriptionMachine,
},
});
// Create a new instance
const instance = await engine.create('order-lifecycle', {
orderId: 'ORD-12345',
customerId: 'CUST-67890',
items: [{ id: 'item-1', sku: 'WIDGET-A', quantity: 2, price: 29.99 }],
shippedItemIds: [],
});
// Send an event
await engine.send(instance.id, { type: 'SUBMIT' });
// Query current state
const current = await engine.getState(instance.id);
console.log(current.state); // 'submitted'
console.log(current.context); // Updated context with payment request initiated
// List all instances in a specific state
const pendingPayments = await engine.query({
machine: 'order-lifecycle',
state: 'payment_pending',
createdBefore: new Date(Date.now() - 86400000), // Older than 24 hours
});The state store records the complete history of every state machine instance: every transition, every event, every context change. This history is invaluable for debugging, auditing, and compliance. You can replay the entire lifecycle of any workflow instance from start to current state.
Visualization and Documentation
One of the greatest advantages of state machines is that they can be automatically visualized. Alfred generates state diagrams from machine definitions, giving your team a visual map of every workflow.
import { StateMachineVisualizer } from '@alfred/state-machine';
const visualizer = new StateMachineVisualizer();
// Generate a Mermaid diagram
const mermaidDiagram = visualizer.toMermaid(orderStateMachine);
console.log(mermaidDiagram);
// stateDiagram-v2
// [*] --> draft
// draft --> submitted : SUBMIT
// submitted --> payment_confirmed : PAYMENT_RECEIVED
// submitted --> payment_pending : PAYMENT_FAILED
// submitted --> cancelled : CANCEL_REQUESTED
// ...
// Generate a DOT graph for Graphviz
const dotGraph = visualizer.toDot(orderStateMachine);
// Export for interactive visualization
const jsonDefinition = visualizer.toJSON(orderStateMachine);
// Can be rendered by state machine visualization toolsThese visualizations are generated directly from the code, so they are always up to date. Include them in your documentation, display them in admin dashboards, and use them in design reviews to verify that your state machine captures the intended business logic.
Practical Tips
Start by mapping out all the states on a whiteboard before writing code. Business stakeholders often understand state diagrams intuitively, making them an excellent communication tool. Get agreement on the states and transitions before implementing them.
Use explicit terminal states. Every workflow should have clearly defined end states marked as final. This makes it easy to identify stuck instances: anything not in a terminal state that has not received an event in a long time may need attention.
Keep the number of states manageable. If your state machine has more than 15 to 20 states, consider whether some states can be modeled as sub-states in a hierarchical machine or whether the workflow should be split into multiple cooperating state machines.
Test every transition path, not just the happy path. State machines make it straightforward to enumerate all possible paths through the workflow. Write tests for each transition, including error paths and edge cases like events arriving in unexpected states.
Conclusion
State machines bring formal rigor to business workflow modeling. By explicitly defining every possible state and transition, they eliminate entire classes of bugs: impossible states, missed edge cases, and undefined behavior. Alfred's state machine engine adds persistence, recovery, and visualization on top of the formal model, making state machines practical for production business workflows.
The declarative nature of state machine definitions makes them simultaneously executable code, documentation, and a communication tool. When a product manager asks "what happens if a customer cancels an order that has already been partially shipped?", the answer is right there in the state machine definition, not buried in a chain of if-else statements spread across multiple files.
Related Articles
Testing Complex Workflows: Strategies and Tools
A comprehensive guide to testing multi-step distributed workflows, covering unit testing individual steps, integration testing complete flows, chaos testing, and time-travel debugging.
Error Recovery Patterns in Workflow Engines
Explore the error recovery patterns used in production workflow engines, from simple retries to complex human-in-the-loop escalation strategies, with a focus on business continuity.
Business Process Automation: Strategy and Implementation
A strategic guide to automating complex business processes with workflow orchestration, covering process discovery, prioritization, and phased implementation with real-world examples.