All Articles
March 10, 20265 min read

Designing a Configurable Workflow Engine

How we built a runtime approval engine that lets admins reconfigure 30+ workflows from the database — no code changes, no redeploys, and requests in-flight keep their original rules.

architecturenestjsworkflow-enginepostgresql

The Problem

Our SFA platform needed approval workflows for everything: sales orders, expense claims, leave requests, price overrides, delivery exceptions. Each workflow had different steps, different approvers, different rules about what happens on rejection.

The first version had hardcoded approval logic. Adding a new workflow meant writing code, testing, deploying — a two-week cycle. The business needed to reconfigure approvals in minutes, not weeks.

The Data Model

We stored workflow definitions in PostgreSQL. Not as JSON blobs. As normalized relational data that could be queried, indexed, and joined with the rest of the application:

-- Defines a workflow type (e.g., "sales_order", "expense_claim")
CREATE TABLE workflows (
  id UUID PRIMARY KEY,
  name VARCHAR(100) NOT NULL,
  module VARCHAR(50) NOT NULL,        -- which module triggers this
  version INT NOT NULL DEFAULT 1,     -- for in-flight request isolation
  is_active BOOLEAN DEFAULT true,
  created_at TIMESTAMPTZ DEFAULT NOW()
);
 
-- Defines approval steps within a workflow
CREATE TABLE workflow_steps (
  id UUID PRIMARY KEY,
  workflow_id UUID REFERENCES workflows(id),
  step_order INT NOT NULL,            -- 1, 2, 3...
  approver_role VARCHAR(50),          -- "district_manager", "regional_head"
  approver_type VARCHAR(20),          -- "role", "specific_user", "hierarchy"
  min_amount DECIMAL,                 -- only trigger above this amount
  can_skip BOOLEAN DEFAULT false,     -- approval can be auto-approved
  timeout_hours INT,                  -- auto-reject if not approved in time
  on_reject VARCHAR(20) DEFAULT 'previous', -- 'previous' | 'start' | 'terminate'
  created_at TIMESTAMPTZ DEFAULT NOW()
);
 
-- Tracks active approval requests
CREATE TABLE approval_requests (
  id UUID PRIMARY KEY,
  workflow_id UUID NOT NULL,
  workflow_version INT NOT NULL,      -- snapshot of rules at creation
  entity_type VARCHAR(50),            -- "order", "claim"
  entity_id UUID,
  current_step INT DEFAULT 1,
  status VARCHAR(20) DEFAULT 'pending', -- 'pending', 'approved', 'rejected'
  requested_by UUID,
  requested_at TIMESTAMPTZ DEFAULT NOW()
);

Versioning: The Critical Detail

The most important design decision: when a request is created, it snapshots the workflow version.

async function startApproval(entityType: string, entityId: string, requestedBy: string) {
  const workflow = await db.workflows.findFirst({
    where: { module: entityType, isActive: true },
    include: { steps: { orderBy: { stepOrder: 'asc' } } },
  });
 
  return db.approvalRequests.create({
    data: {
      workflowId: workflow.id,
      workflowVersion: workflow.version, // Snapshot!
      entityType,
      entityId,
      requestedBy,
      status: 'pending',
      currentStep: 1,
    },
  });
}

This means an admin can add a fourth approval step to the sales order workflow, and it only affects new requests. Existing requests continue with the three-step version they started with. No broken in-flight workflows.

The Approval Engine

The engine evaluates each step at runtime:

async function processStep(request: ApprovalRequest): Promise<StepResult> {
  const step = await getWorkflowStep(request.workflowId, request.workflowVersion, request.currentStep);
 
  // Check if step applies (e.g., min_amount threshold)
  if (step.minAmount) {
    const entity = await loadEntity(request.entityType, request.entityId);
    if (entity.amount < step.minAmount) {
      if (step.canSkip) {
        return advanceToNextStep(request); // Auto-approve below threshold
      }
      return { action: 'skip', reason: 'below_min_amount' };
    }
  }
 
  // Find approver
  const approver = await resolveApprover(step, request);
 
  // Create approval task
  return {
    action: 'await_approval',
    approverId: approver.id,
    approverName: approver.name,
    role: step.approverRole,
  };
}

Rejection Behavior

The on_reject field controls what happens when an approver rejects:

  • previous (default): The request goes back one step. The same person who approved step 2 has to re-approve after fixes. Prevents "start over" frustration.
  • start: Request goes back to step 1. Used for compliance-critical workflows like expense claims above $500.
  • terminate: Request is rejected outright. Used for final approval steps.
async function handleRejection(request: ApprovalRequest, reason: string) {
  const step = await getWorkflowStep(request.workflowId, request.workflowVersion, request.currentStep);
 
  switch (step.onReject) {
    case 'previous':
      await db.approvalRequests.update({
        where: { id: request.id },
        data: { currentStep: Math.max(1, request.currentStep - 1), status: 'pending' },
      });
      break;
    case 'start':
      await db.approvalRequests.update({
        where: { id: request.id },
        data: { currentStep: 1, status: 'pending' },
      });
      break;
    case 'terminate':
      await db.approvalRequests.update({
        where: { id: request.id },
        data: { status: 'rejected', rejectionReason: reason },
      });
      break;
  }
 
  await notifyRequester(request, reason);
}

The SAP Integration

After final approval, sales orders pushed to SAP. But SAP was occasionally down. We couldn't let that block the approval flow:

async function onFinalApproval(request: ApprovalRequest) {
  // Approval is done — mark it immediately
  await db.approvalRequests.update({
    where: { id: request.id },
    data: { status: 'approved' },
  });
 
  // Push to SAP asynchronously with circuit breaker
  sapQueue.add('push-order', {
    orderId: request.entityId,
    approvalRequestId: request.id,
  }, {
    attempts: 5,
    backoff: { type: 'exponential', delay: 60000 },
  });
}

What We Learned

  1. Version everything — The moment you let admins reconfigure workflows, you need versioning. Without it, changing a workflow mid-flight creates undefined behavior.
  2. on_reject = 'previous' is the best default — "Start over" punishes the approver who correctly rejected something. "Previous step" creates accountability without frustration.
  3. Publish events, not just state changes — Every approval action (submitted, approved, rejected, escalated) publishes an event. Other services subscribe — notifications, audit logs, SAP. The workflow engine doesn't know about them.

After six months in production, 30+ workflows ran across 20 modules. Admins reconfigured approval chains from the database. No code changes. No redeploys.

Written by

Md. Shahabuddin Bhuiyan

Senior Software Engineer specializing in distributed systems, event-driven microservices, and cloud resilience.