Every business process that involves approval — leave requests, purchase orders, payment vouchers — is fundamentally a state machine. A document starts in one state (DRAFT), transitions through intermediate states (PENDING_L1, PENDING_L2), and ends in a terminal state (APPROVED or REJECTED). Building this naively — with ad-hoc if/else logic per feature — leads to duplicated code, inconsistent behavior, and a maintenance nightmare when approval rules change. As a freelance ERP consultant, when I built a custom ERP for a client, I extracted a generic workflow engine that powers all approval flows in the system.
Why a Generic Workflow Engine?
The alternative to a generic engine is writing custom approval logic for each business process. This approach works for one or two processes but fails when requirements change — 'we need a second approval level for amounts over Rp 50 million' means touching every approval flow independently. A generic engine means you update the transition table once, and all processes that share that configuration benefit immediately.
State Machine Design Principles
A state machine for business workflows needs four things: a finite set of states, a finite set of actions (transitions), a table mapping (from_state, action, required_role) → to_state, and a way to reject invalid transitions. The key constraint is that only one transition should match a given (current_state, action, actor_role) triple. In my implementation, the transitions are defined as a TypeScript constant array, which gives you compile-time type safety and easy runtime introspection.
JSONB History for Full Audit Trail
Every state transition is appended to a JSONB column called 'history' on the workflow_instances table. This gives a complete, ordered record of who did what and when — without needing a separate history table. The JSONB approach is simple and fast for typical approval chains (5–15 steps), and PostgreSQL's JSONB operators make it easy to query.
Multi-Step Approval Workflow State Machine
┌───────────┐ submit() ┌───────────────┐
│ DRAFT │──────────────►│ PENDING_L1 │
└───────────┘ │ (Mgr Approval)│
└───────┬───────┘
│
┌─────────────────┼──────────────────┐
│ approve() │ │ reject()
▼ │ ▼
┌────────────────┐ │ ┌───────────────┐
│ PENDING_L2 │ │ │ REJECTED │
│ (Dir Approval) │ │ └───────────────┘
└───────┬────────┘ │
│ │ revise()
┌───────────┼──────────┐ ▼
│ approve() │ │ ┌────────────┐
▼ │ reject() │ │ REVISION │──► back to DRAFT
┌──────────┐ ▼ │ └────────────┘
│PENDING_FIN│ ┌───────────┐│
│(Finance) │ │ REJECTED ││
└────┬─────┘ └───────────┘│
│ │
│ approve() │
▼
┌──────────────┐
│ APPROVED │──► Trigger downstream actions
└──────────────┘ (email, PDF, payment run)
PostgreSQL table: workflow_instances
Columns: id, entity_type, entity_id, current_state,
history JSONB, created_at, updated_atUse PostgreSQL's gen_random_uuid() for workflow instance IDs, and store the entity_type + entity_id separately. This makes the workflow engine truly generic — the same WorkflowService powers leave requests, AP vouchers, and any future process without any code changes to the engine itself.
The NestJS Workflow Service
The WorkflowService has a single public method: transition(instanceId, action, actorRole, comment). It loads the workflow instance, finds the matching transition, validates the actor's role, applies the state change, appends to the JSONB history, and emits a domain event for downstream handlers. Downstream handlers listen for specific workflow state events using NestJS's EventEmitter2 — this keeps the workflow engine decoupled from business logic.
React UI for Workflow Visualization
The React component for workflow state has two parts: a status badge showing the current state with color coding (DRAFT=gray, PENDING=yellow, APPROVED=green, REJECTED=red), and a collapsible timeline showing the full transition history. The timeline reads from the JSONB history array and renders each entry with the actor, action, timestamp, and optional comment.
// NestJS: Generic workflow state machine service
// Works for Leave Requests, AP Vouchers, AR Invoices etc.
export type WorkflowState =
| 'DRAFT' | 'PENDING_L1' | 'PENDING_L2' | 'PENDING_FINANCE'
| 'APPROVED' | 'REJECTED' | 'REVISION';
export type WorkflowAction =
| 'submit' | 'approve' | 'reject' | 'revise' | 'cancel';
interface Transition {
from: WorkflowState;
action: WorkflowAction;
to: WorkflowState;
requiredRole: string;
}
const TRANSITIONS: Transition[] = [
{ from: 'DRAFT', action: 'submit', to: 'PENDING_L1', requiredRole: 'EMPLOYEE' },
{ from: 'PENDING_L1', action: 'approve', to: 'PENDING_L2', requiredRole: 'MANAGER' },
{ from: 'PENDING_L1', action: 'reject', to: 'REJECTED', requiredRole: 'MANAGER' },
{ from: 'PENDING_L1', action: 'revise', to: 'REVISION', requiredRole: 'MANAGER' },
{ from: 'PENDING_L2', action: 'approve', to: 'PENDING_FINANCE', requiredRole: 'DIRECTOR' },
{ from: 'PENDING_L2', action: 'reject', to: 'REJECTED', requiredRole: 'DIRECTOR' },
{ from: 'PENDING_FINANCE', action: 'approve', to: 'APPROVED', requiredRole: 'FINANCE' },
{ from: 'PENDING_FINANCE', action: 'reject', to: 'REJECTED', requiredRole: 'FINANCE' },
{ from: 'REVISION', action: 'submit', to: 'PENDING_L1', requiredRole: 'EMPLOYEE' },
];
@Injectable()
export class WorkflowService {
constructor(
@InjectRepository(WorkflowInstance)
private workflowRepo: Repository<WorkflowInstance>,
private eventEmitter: EventEmitter2,
) {}
async transition(
instanceId: string,
action: WorkflowAction,
actorRole: string,
comment?: string,
): Promise<WorkflowInstance> {
const instance = await this.workflowRepo.findOneOrFail({ where: { id: instanceId } });
const transition = TRANSITIONS.find(
t => t.from === instance.currentState
&& t.action === action
&& t.requiredRole === actorRole,
);
if (!transition) {
throw new ForbiddenException(
`Action "${action}" not allowed from state "${instance.currentState}" for role "${actorRole}"`
);
}
// Append to JSONB history
const historyEntry = {
from: instance.currentState,
to: transition.to,
action,
actorRole,
comment,
timestamp: new Date().toISOString(),
};
await this.workflowRepo.update(instanceId, {
currentState: transition.to,
history: () => `history || '${JSON.stringify(historyEntry)}'::jsonb`,
});
// Emit event for downstream handlers (email, notifications)
this.eventEmitter.emit(`workflow.${transition.to.toLowerCase()}`, {
instanceId,
entityType: instance.entityType,
entityId: instance.entityId,
});
return this.workflowRepo.findOneOrFail({ where: { id: instanceId } });
}
}Handling Edge Cases: Revisions and Cancellations
Real-world approval flows have two common edge cases: revision requests (the approver sends a document back for changes) and cancellations (the submitter withdraws the request). Both need their own states — REVISION and CANCELLED — and their own transition rules. The REVISION state allows the original submitter to update the document and resubmit. The CANCELLED state is terminal — a cancelled document cannot be resubmitted.
Don't try to make the workflow engine configuration-driven from a database UI in your first version. The temptation to let admins draw workflow diagrams in the browser is real — but it adds enormous complexity (validation, cycle detection, role resolution) that you probably don't need yet. Start with hard-coded TypeScript transition tables, get the engine working reliably, then add configuration features only when users actually ask for them.
Testing Workflow Logic
The workflow engine is the heart of the ERP — bugs here affect every approval process in the system. Write comprehensive unit tests for every valid transition and every invalid one. For each transition: test that the correct role can perform it, test that an incorrect role is rejected with a ForbiddenException, and test that the history is correctly appended. Use Jest's test.each to cover all transitions without repetitive test code.
Production Lessons: What I'd Do Differently
After 12 months of running this workflow engine in production: First, use optimistic locking (a version column) on workflow_instances to prevent concurrent transition conflicts. Second, persist the full actor identity in the JSONB history rather than just the role string. Third, add a 'delegation' feature earlier — managers who go on leave need to delegate their approval authority to a deputy.

