Building a Workflow Engine for Multi-Step Approvals: NestJS, PostgreSQL, and React

Photo by Unsplash

Photo by Unsplash
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. When I built the ERP for Commsult Indonesia, I extracted a generic workflow engine that powers all approval flows in the system.
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.
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 our implementation, the transitions are defined as a TypeScript constant array, which gives you compile-time type safety and easy runtime introspection.
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 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.
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 } });
}
}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.
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.
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.