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

Photo by Unsplash
Writing custom approval logic per business process works initially but becomes a maintenance problem as requirements change — for example, adding a second approval level for amounts over a threshold means touching every approval flow independently. A generic engine externalises the rules into a transition table, so you update the configuration once and all processes sharing that configuration benefit immediately. This is the approach taken when building the ERP for Commsult Indonesia, where the same WorkflowService powers leave requests, AP vouchers, and AR invoices.
Every state transition is appended to a JSONB column called 'history' on the workflow_instances table, giving a complete ordered record of who did what and when. PostgreSQL's JSONB operators make it straightforward to query this history, and the approach is fast enough for typical approval chains of 5–15 steps. The workflow instance also stores entity_type and entity_id separately so the engine remains truly generic across document types.
The WorkflowService exposes a single public method — transition(instanceId, action, actorRole, comment) — that loads the instance, finds the matching transition, validates the actor's role, applies the state change, and appends to the JSONB history. Rather than calling business logic directly, it emits a domain event, and downstream handlers listen for specific workflow state events using NestJS's EventEmitter2. This event-driven separation means the engine itself never needs to know about leave policies, invoice rules, or any other domain concern.
Because the workflow engine affects every approval process in the system, the post recommends writing comprehensive unit tests for every valid transition and every invalid one. For each transition you should verify that the correct role can perform it, that an incorrect role is rejected with a ForbiddenException, and that the history is correctly appended. Using Jest's test.each covers all transitions without repetitive test code.
Three improvements stand out: first, add optimistic locking via 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 so that managers on leave can hand off their approval authority to a deputy. The post also warns against making the engine configuration-driven from a database UI in the first version, recommending hard-coded TypeScript transition tables until users explicitly ask for configurability.

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. 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.
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 my 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.