Multi-Level Approval Workflows in Node.js — Without BPMN
Approval chains are everywhere in enterprise software — purchase orders, expense reports,
leave requests, contracts. Yet most teams hand-roll them with a pile of if
statements and a status column, or reach for a heavyweight BPMN engine.
Here's a lighter way.
The problem with rolling your own
A "simple" approval feature never stays simple. First it's one approver. Then it's
manager-then-finance. Then finance only above a threshold. Then someone goes on leave and
you need delegation. Then compliance asks for an immutable audit trail. Then two requests
race and your status column ends up in an impossible state.
BPMN engines (Camunda) and durable workflow runtimes (Temporal) can model this, but they bring servers, DSLs, and operational weight that a CRUD app rarely wants. hierarchical-approval is the middle ground: a focused, embeddable TypeScript engine for multi-level approvals — with optimistic locking, idempotency, and an audit trail — and nothing to deploy.
Install
npm install hierarchical-approval
# PostgreSQL is an optional peer dependency, only for production
npm install pg @types/pg Define a template
A template describes the chain. Levels resolve in order; each level has a
mode (any, all, majority, quorum, or weighted). Conditions add levels
based on the document's data — here, orders over $10,000 get a CFO step.
import { ApprovalEngine } from 'hierarchical-approval';
import { MemoryAdapter } from 'hierarchical-approval/adapters/memory';
const engine = new ApprovalEngine({ adapter: new MemoryAdapter() });
await engine.defineTemplate({
name: 'Purchase Order',
documentType: 'purchase_order',
levels: [
{ level: 1, name: 'Line Manager', approvers: [{ type: 'user', userId: 'manager_alice' }], mode: 'any' },
{ level: 2, name: 'Finance', approvers: [{ type: 'user', userId: 'finance_bob' }], mode: 'any' },
],
// Orders over $10,000 automatically gain a CFO level:
conditions: [{
when: { field: 'amount', operator: '>', value: 10_000 },
addLevels: [{ level: 3, name: 'CFO', approvers: [{ type: 'user', userId: 'cfo_carol' }], mode: 'any' }],
}],
}); Submit and approve
Submitting an instance evaluates the conditions and computes the chain. Each
approve() advances to the next level; the final approval flips the instance to
approved.
const po = await engine.submit({
templateName: 'Purchase Order',
documentId: 'PO-001',
documentType: 'purchase_order',
submittedBy: 'employee_jane',
data: { amount: 4_000, vendor: 'Acme Corp' },
});
await engine.approve(po.id, { approverId: 'manager_alice', comment: 'Budget available' });
await engine.approve(po.id, { approverId: 'finance_bob' }); // → status: 'approved' 👉 Try this exact flow in the interactive playground →
Delegation and rejection
Real approvals aren't always a clean "yes." Approvers go on leave; requests get bounced. Both are first-class — and both are recorded.
// Finance is on leave — hand the pending step to a colleague:
await engine.delegate(po.id, {
fromApprover: 'finance_bob',
toApprover: 'finance_charlie',
reason: 'On leave until Monday',
});
// Or reject and stop the chain (only an approver of the open level can):
await engine.reject(po.id, { approverId: 'finance_charlie', reason: 'Not in this quarter budget' });
By default a rejection that fails its level fails the whole instance
(returnTo: 'originator'). Pass returnTo: 'previous' to reset
the levels after the prior one and replay from there instead. When the rejected level is
one branch of a parallel group, "previous" steps back past the whole group, not into a
sibling branch. It throws a validation error if the failing level is the first in the
chain.
The audit trail (and why locking matters)
Every action appends an immutable entry. This isn't a nice-to-have in ERP — it's what an
auditor asks for. And because every instance carries a version, concurrent
approvals can't silently clobber each other: the engine uses optimistic locking and throws
an ApprovalConflictError (retried automatically) instead of corrupting state.
Idempotency keys make a retried submit() safe, too.
const history = await engine.getHistory(po.id);
// [
// { action: 'submitted', actorId: 'employee_jane', level: 1, timestamp: ... },
// { action: 'approved', actorId: 'manager_alice', level: 1, timestamp: ... },
// { action: 'delegated', actorId: 'finance_bob', ... },
// ...
// ] Drop it into NestJS
If you're on NestJS, the hierarchical-approval/nestjs subpath provides a
module that wires the engine into DI. @nestjs/common is an optional peer
dependency — you only pull it in if you use it.
import { Module } from '@nestjs/common';
import { HierarchicalApprovalModule } from 'hierarchical-approval/nestjs';
import { MemoryAdapter } from 'hierarchical-approval/adapters/memory';
@Module({
imports: [
HierarchicalApprovalModule.forRoot({ adapter: new MemoryAdapter(), isGlobal: true }),
],
})
export class AppModule {} Then inject the engine anywhere:
import { Injectable } from '@nestjs/common';
import { ApprovalEngine } from 'hierarchical-approval';
import { InjectApprovalEngine } from 'hierarchical-approval/nestjs';
@Injectable()
export class InvoiceService {
constructor(@InjectApprovalEngine() private readonly approvals: ApprovalEngine) {}
submit(invoiceId: string, amount: number) {
return this.approvals.submit({
templateName: 'Invoice',
documentId: invoiceId,
documentType: 'invoice',
submittedBy: 'system',
data: { amount },
});
}
} forRootAsync is available too, for building the engine from your
ConfigService. The module also stops the escalation scheduler on application
teardown.
Going to production with PostgreSQL
The MemoryAdapter is perfect for tests and demos. For production, swap in the
PostgresAdapter — the engine code doesn't change, only the adapter.
import { PostgresAdapter } from 'hierarchical-approval/adapters/postgres';
const adapter = new PostgresAdapter({ connectionString: process.env.DATABASE_URL });
await adapter.migrate(); // create tables once, at startup
const engine = new ApprovalEngine({ adapter }); Where to go next
- Interactive playground — build a chain and click through it.
- npm package — install and read the full README.
- GitHub — source, examples, and the enterprise plug-ins (audit, metrics, tracing, notify, resilience).
npm install hierarchical-approval