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.
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
- Version everything — The moment you let admins reconfigure workflows, you need versioning. Without it, changing a workflow mid-flight creates undefined behavior.
on_reject = 'previous'is the best default — "Start over" punishes the approver who correctly rejected something. "Previous step" creates accountability without frustration.- 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.