Skip to content
Kien

A multi-level approval engine no module has to reach into

One configurable approval engine shared by several business modules — with boundaries enforced by lint, configuration snapshotted at submit, and per-level SLA reminders running on a delayed-job queue.

Period
2026
Role
Designer and owner — backend, integration handbook, flow diagrams

Stack

  • NestJS 10
  • Fastify
  • TypeScript
  • TypeORM
  • PostgreSQL
  • Redis
  • BullMQ
  • Kafka
  • Jest

The problem

Several business modules needed one configurable multi-level approval flow with per-level deadlines, configured differently per tenant. But if the engine reads a module's tables, or a module imports engine internals, they are welded together within six months.

Three further problems: configuration keeps changing under in-flight requests; two approvers can click at the same instant; and a manager approves a hundred requests in one action.

The approach

One public gateway class is the engine's entire surface. The services behind it are not exported, and lint rules block deep imports.

The return path is inverted: approval emits domain events and business modules subscribe. Modules register a log source at boot so their history merges into the approval timeline without the engine knowing their tables. Cross-module relations use indexed entity_type + entity_idnever foreign keys across modules.

On submit, the whole workflow tree including per-level deadlines is snapshotted, so later configuration edits cannot rewrite history.

Approve and reject carry an optimistic lock returning 409 on a stale write, and approving a level marks peers ignored and opens the next level in the same transaction.

Bulk is a separate path: one transaction, statements grouped per table in a fixed order rather than interleaved row locks; invalid items are skipped with their error code instead of failing the batch.

Per-level SLA uses two delayed jobs as pure clocks, not the source of truth: the worker re-reads the database when it fires and does nothing if the level was already handled — so no job ever needs cancelling. Job IDs are deterministic, so rescheduling cannot duplicate mail. Redis being down loses reminders but never blocks approving.

Where that failure domain stops is worth drawing:

approve / reject decided inside the DB transaction after commit — fail-soft dispatch commit mail with exact recipient lists owned by the engine, never guessed SLA reminders — delayed jobs on Redis pure clocks, not the source of truth Redis down: reminders are lost — approving is never blocked a fired job re-reads the DB and stands down if the level was already handled — no job ever needs cancelling
Everything to the right of the commit line is fail-soft: it can fail without undoing anything to the left of it. The database stays the only source of truth — a fired reminder job re-reads it instead of trusting the queue.

The result

50 TypeScript files behind a single gateway class, 11 documented error codes, 7 flow diagrams, and 18 user stories written back from delivered code.

Notification ownership is explicit: the engine owns every mail belonging to the approval flow and returns the exact recipient lists so callers never guess. All dispatch happens after commit and is fail-soft — a mail failure can never undo an approval.