Eighteen months ago I built a multi-level purchase order approval system for an ERP client. Six months later I built another one for leave requests. Three months after that, a third for vendor onboarding. Each time the business logic was nearly identical — sequential levels, conditional chains based on document data, an audit trail, race condition handling when two approvers click at the same time — and each time I rewrote it from scratch. On the third rebuild I stopped, extracted the pattern, added the correctness guarantees I'd learned the hard way, and published it as hierarchical-approval on npm.
The same code, written three times
Every ERP module that involves money, headcount, or compliance has an approval step. The shapes vary — purchase orders route by amount, leave requests route by department, vendor onboarding routes by risk tier — but the underlying machinery is the same. You need: a reusable chain definition (the template), an instance that tracks each document's progress through that chain, sequential level advancement, conditional level insertion at submit time, an immutable audit log, and handling for the case where two approvers act on the same level simultaneously. Writing that correctly every time is repetitive, and the subtle parts — especially the concurrency handling — are easy to get wrong.
The pattern I kept rebuilding — every time from scratch:
Document submitted
│
▼
┌─────────────────────────────────────┐
│ Evaluate conditions on data │
│ e.g. amount > 10,000 → add CFO │
└──────────────┬──────────────────────┘
│
┌───────▼────────┐
│ Level 1 │──APPROVE──▶ advance to next level
│ Manager │──REJECT───▶ notify submitter
└───────┬────────┘
│ (if more levels)
┌───────▼────────┐
│ Level 2 │──APPROVE──▶ advance / complete
│ Director │──REJECT───▶ cancel remaining chain
└────────────────┘
Built this 3× in 18 months. On the 3rd time, I extracted it.Why existing libraries did not fit
I looked at what was available on npm before writing my own. The general-purpose workflow engines are powerful but require you to implement every guard, every condition, every audit entry yourself — they give you a state machine, not an approval workflow. The approval-specific packages I found were unmaintained, had no TypeScript support, and most were single-level only. None had idempotency, none had optimistic locking, and none had a way to inject a test clock. That last point was the deciding factor: if I cannot control time in tests, I cannot reliably test SLA and escalation logic without real waits.
The key design decision: templates and instances
The central abstraction is a template — a reusable chain definition stored by name. When a document is submitted, the engine creates an instance from that template and evaluates conditional rules against the document data to determine which levels apply. Conditions are evaluated once at submit time and frozen into the instance, so a template change mid-flight never affects an in-progress approval. The instance carries a version number for optimistic locking and an idempotency key so a double-submitted document returns the existing instance rather than creating a duplicate.
The idempotency key defaults to a SHA-256 hash of tenantId + documentType + documentId + templateName. You can override it with a custom function if your business logic requires compound keys — for example, including a fiscal period so a document can go through the same template again in a new period.
Getting started: define a template, submit, approve
The API is deliberately minimal. Define a template once, then call submit, approve, reject, delegate, or cancel on any instance. The library handles level advancement, SLA tracking, escalation scheduling, and event emission automatically. Here is a complete purchase order approval flow with a conditional finance level:
import { ApprovalEngine } from 'hierarchical-approval';
import { MemoryAdapter } from 'hierarchical-approval/adapters/memory';
const engine = new ApprovalEngine({
adapter: new MemoryAdapter(),
tenantId: 'acme',
});
// Define the approval chain once — reuse across any document
await engine.defineTemplate({
name: 'purchase-order',
documentType: 'purchase_order',
levels: [
{ level: 1, name: 'Manager',
approvers: [{ type: 'user', userId: 'mgr-1' }], mode: 'any' },
],
// Finance level only activates if amount > 10,000
conditions: [{
when: { field: 'amount', operator: '>', value: 10000 },
addLevels: [{
level: 2, name: 'Finance',
approvers: [{ type: 'user', userId: 'fin-1' }], mode: 'any',
}],
}],
slaDeadlineDays: 2,
});
// Submit a document — idempotent (retry-safe)
const instance = await engine.submit({
templateName: 'purchase-order',
documentId: 'po-0042',
documentType: 'purchase_order',
submittedBy: 'alice',
data: { amount: 15000 },
});
// Approve at each level
await engine.approve(instance.id, { approverId: 'mgr-1' });
await engine.approve(instance.id, { approverId: 'fin-1' });
// instance.status === 'approved'Four problems that required real engineering
Clean API aside, most of the library's code is in four correctness problems that come up in production but are invisible in simple examples.
Concurrency: optimistic locking with retry
In an ERP with hundreds of active users, two managers can approve the same level at the same time. Without a concurrency control, both reads see version 1, both writes succeed, and the second approval silently overwrites the first — the audit log loses an entry and the level may skip incorrectly. The library solves this with a version field on every instance. Every write calls updateInstance with the version it read; the storage adapter throws ApprovalConflictError if the stored version has moved. The engine retries automatically with exponential backoff, configurable via retryPolicy.
Idempotency: network retries and double-clicks
Network retries and button double-clicks are a real problem in approval UIs. Submitting the same document twice should not create two instances. The library hashes the submission context into an idempotency key, checks for an existing instance with that key before writing, and returns the existing one if found. This is transparent to the caller — the second submit call returns the same instance as the first without any special handling.
// Configurable retry policy for optimistic locking
const engine = new ApprovalEngine({
adapter: new PostgresAdapter({ connectionString: process.env.DATABASE_URL }),
tenantId: 'acme',
retryPolicy: {
maxAttempts: 5,
baseDelayMs: 100,
maxDelayMs: 2000,
jitter: true, // randomise delay to avoid thundering-herd
},
});
// Idempotency: submitting the same document twice
// returns the existing instance instead of creating a duplicate
const a = await engine.submit({ templateName: 'po', documentId: 'po-0042', ... });
const b = await engine.submit({ templateName: 'po', documentId: 'po-0042', ... });
console.log(a.id === b.id); // trueTemplate snapshots insulate in-flight instances from template updates. When you call updateTemplate, the engine creates a new template version with an incremented version counter. Existing instances keep their original escalation and SLA configuration in a frozen templateSnapshot. Update templates freely — you will not break running approvals.
Enterprise adapters: plug in your stack
The library is designed to live inside whatever infrastructure you already have. Six adapter interfaces let you connect your notification system, metrics backend, audit sink, job scheduler, authorization policy, and operation middleware without forking the library. All adapters are fire-and-forget — a failure in any adapter is caught, logged, and swallowed. A broken Kafka connection will never prevent an approval from completing.
// Plug in whatever notification/metrics/audit system you use
const engine = new ApprovalEngine({
adapter: pgAdapter,
tenantId: 'acme',
// Fire notifications after every approval event
notificationAdapter: {
notify: async (event) => {
if (event.type === 'approval:level_advanced') {
await slack.post(event.recipients, 'Your approval is needed');
}
},
},
// Write to Kafka / S3 / CloudTrail in addition to the DB
auditAdapter: {
append: async (tenantId, instanceId, entry, instance) => {
await kafka.send({ topic: 'approvals.audit',
messages: [{ value: JSON.stringify({ tenantId, entry }) }] });
},
},
// Prometheus / Datadog counters
metricsAdapter: {
increment: (metric, labels) => counters[metric].labels(labels).inc(),
timing: (metric, ms, labels) => histograms[metric].observe(ms / 1000),
},
// Authorization: signing authority cap
authorizationPolicy: {
authorize: async (ctx) => {
if (ctx.operation === 'approve') {
const cap = await budgetSystem.getCap(ctx.actorId);
if (ctx.instance.data.amount > cap)
return 'Signing cap exceeded';
}
},
},
});Testing approval logic without pain
The hardest part of testing approval workflows is time. SLA breach, escalation, and deadline tests all require time to pass, and real timers in tests are slow, flaky, and a debugging nightmare. The library ships a ManualClock and an ApprovalTestKit in a dedicated testing sub-path. ManualClock lets you jump days instantly. ApprovalTestKit.create gives you a pre-wired engine with a MemoryAdapter and ManualClock. ApprovalTestKit.fullyApprove drives an instance through all levels in one call. No real timers, no database, deterministic in all cases.
import { ApprovalTestKit, ManualClock } from 'hierarchical-approval/testing';
test('SLA breach fires after 2 days', async () => {
// Pre-wired: MemoryAdapter + ManualClock at 2025-01-01
const { engine, clock } = ApprovalTestKit.create();
await engine.defineTemplate({
name: 'invoice',
documentType: 'invoice',
levels: [{ level: 1, name: 'Mgr',
approvers: [{ type: 'user', userId: 'mgr' }], mode: 'any' }],
slaDeadlineDays: 2,
});
const { id } = await engine.submit({
templateName: 'invoice', documentId: 'inv-1',
documentType: 'invoice', submittedBy: 'alice', data: {},
});
const breached = [];
engine.on('approval:sla_breached', (p) => breached.push(p.instanceId));
// No real timers — jump 3 days instantly
clock.advanceDays(3);
await engine['escalation'].tick();
expect(breached).toContain(id); // deterministic, zero ms wall-clock
await engine.shutdown();
});
// Walk all levels in one line
const final = await ApprovalTestKit.fullyApprove(engine, id, {
1: 'manager-id',
2: 'director-id',
});
// final.status === 'approved'Install and explore
The library is published on npm as hierarchical-approval, written in TypeScript 5 with strict mode enabled, dual CJS and ESM output, and full type declarations. Peer dependency on pg is optional and only needed for the Postgres adapter. The testing sub-path is intentionally kept out of the main bundle so it never ships to production.
hierarchical-approval on npm
TypeScript-first. Dual CJS/ESM. Zero required peer deps for the core.
npmjs.com/package/hierarchical-approval
