Most workflow engines fail at enterprise scale for a reason that has nothing to do with throughput or latency. They fail because they model the process, not the organization. The moment you hardcode "request → manager → finance → approved" as a fixed sequence of steps, you've created a system that's correct for exactly one snapshot of a company that will never stop changing.
This is a write-up of the architectural pattern we use at APSentra to solve that — treating the organization itself as a live, queryable graph, and deriving approval workflows from it rather than configuring them as static state machines.
The Core Problem with Process-First Design
A procurement request needs to be approved. The intuitive implementation:
typescript
type ApprovalStep = {
approver_id: string;
order: number;
approval_limit: number;
};
type Workflow = {
steps: ApprovalStep[];
};
This works until it doesn't. The immediate failure modes at enterprise scale:
Structural drift: org changes daily, but workflow definitions are deployment-time configuration
Context blindness: who approves depends on amount, category, cost center, requester's position, available budget — none of which a static step list can encode
Audit gaps: approved_by: user_4471 tells an auditor nothing about what authority that person had at that specific moment
The fix isn't a smarter workflow engine. It's moving the organization's structure into the data layer so that routing logic can query it at runtime.
The Graph Model
We represent the organization as a directed graph stored in a graph database (we use a relational DB with adjacency tables for portability, but the query patterns are graph traversals regardless):
Nodes:
Entity { id, name, currency, jurisdiction }
BusinessUnit { id, entity_id, name }
CostCenter { id, business_unit_id, name, budget_pool_id }
BudgetPool { id, total, committed, available }
Role { id, name, approval_limit_by_category: JSON }
Employee { id, name, role_id, cost_center_id, is_active }
Edges:
REPORTS_TO (Employee -> Employee)
DELEGATES_TO (Employee -> Employee, valid_from, valid_until, scope)
OWNS (Employee -> CostCenter)
DRAWS_FROM (CostCenter -> BudgetPool)
GOVERNS (Role -> Category, approval_limit)
A key design constraint: the graph is versioned, not mutated in place. When a department restructures or someone's role changes, we write a new edge with a valid_at timestamp rather than updating the existing record. This is what makes it possible to answer "who had authority to approve this, and what was their reporting line, on the date this request was submitted" — which is exactly the audit question.
-- Find the valid reporting chain for an employee at a point in time
WITH RECURSIVE approval_chain AS (
SELECT e.id, e.role_id, r.approval_limit, 0 as depth
FROM employees e
JOIN roles r ON r.id = e.role_id
WHERE e.id = :requester_id
UNION ALL
SELECT mgr.id, mgr_role.id, mgr_role.approval_limit, ac.depth + 1
FROM reports_to rt
JOIN employees mgr ON mgr.id = rt.manager_id
JOIN roles mgr_role ON mgr_role.id = mgr.role_id
JOIN approval_chain ac ON ac.id = rt.employee_id
WHERE rt.valid_at <= :request_timestamp
AND (rt.valid_until IS NULL OR rt.valid_until > :request_timestamp)
AND ac.depth < 10 -- circuit breaker
)
SELECT * FROM approval_chain
WHERE approval_limit >= :request_amount
ORDER BY depth ASC
LIMIT 1;
Approval as a Generated State Machine
Once the org is data, approval stops being a configured sequence and becomes a computed state machine whose transitions are resolved against the graph at evaluation time.
type RequestState =
| 'DRAFT'
| 'SUBMITTED'
| 'BUDGET_CHECK_PENDING'
| 'BUDGET_REJECTED'
| { type: 'APPROVAL_PENDING'; level: number; approver_id: string }
| { type: 'ESCALATED'; escalated_to: string; reason: string }
| 'APPROVED'
| 'REJECTED'
| 'CONVERTED_TO_TENDER'
| 'CONVERTED_TO_PO';
type DomainEvent =
| { type: 'RequestSubmitted'; request_id: string; requester_id: string; amount: number; cost_center_id: string; category_id: string; timestamp: Date }
| { type: 'BudgetReserved'; request_id: string; pool_id: string; amount: number; timestamp: Date }
| { type: 'ApprovalGranted'; request_id: string; approver_id: string; authority_basis: string; timestamp: Date }
| { type: 'ApprovalEscalated'; request_id: string; from_id: string; to_id: string; reason: string; timestamp: Date }
| { type: 'ApprovalRejected'; request_id: string; approver_id: string; reason: string; timestamp: Date };
The transition function resolves the next state by querying the org graph:
async function resolveNextApprover(
request: ProcurementRequest,
currentDepth: number,
orgGraph: OrgGraphService
): Promise<{ approver_id: string; authority_basis: string } | null> {
// Walk reporting chain from requester's cost center
const chain = await orgGraph.getApprovalChain({
employee_id: request.requester_id,
min_approval_limit: request.amount,
category_id: request.category_id,
as_of: request.submitted_at,
start_depth: currentDepth,
});
if (!chain.length) return null;
const candidate = chain[0];
// Check for active delegation
const delegation = await orgGraph.getActiveDelegation({
employee_id: candidate.id,
as_of: new Date(),
});
if (delegation) {
// Return delegate, but record original authority for audit trail
return {
approver_id: delegation.delegate_id,
authority_basis: `delegated_from:${candidate.id}:${delegation.id}`,
};
}
return {
approver_id: candidate.id,
authority_basis: `role:${candidate.role_id}:limit:${candidate.approval_limit}`,
};
}
The critical difference from a standard workflow engine: a 3-level approval chain and a 9-level one are the same code path. The loop just walks more or fewer edges. Reorganize the company, and the same deployed code routes correctly the next morning.
Budget Reservation as an Atomic Guard
One pattern that's easy to get wrong: validating budget and reserving it need to be the same atomic operation, not a check followed by a separate commit. Classic double-spend failure:
Thread A: checks pool balance → $50k available ✓
Thread B: checks pool balance → $50k available ✓
Thread A: raises request for $40k
Thread B: raises request for $40k
→ Both approved. Pool overdrawn by $30k.
The fix is an optimistic lock on the budget pool row:
-- Budget reservation with optimistic locking
BEGIN;
SELECT id, available, version
FROM budget_pools
WHERE id = :pool_id
FOR UPDATE;
-- Guard condition
IF available < :requested_amount THEN
ROLLBACK;
RETURN 'BUDGET_REJECTED';
END IF;
UPDATE budget_pools
SET
committed = committed + :requested_amount,
available = available - :requested_amount,
version = version + 1
WHERE id = :pool_id
AND version = :read_version; -- optimistic lock check
INSERT INTO domain_events (type, payload, occurred_at)
VALUES ('BudgetReserved', :payload, NOW());
COMMIT;
If the UPDATE affects 0 rows (version mismatch from a concurrent commit), we retry. This keeps budget validation O(1) per request rather than requiring distributed locks.
Event Sourcing for Audit-Ready Traceability
The state machine never mutates a "status" column. Every transition is a domain event appended to an immutable log:
// What NOT to do — loses the audit trail
await db.requests.update({ id }, { status: 'APPROVED', approved_by: userId });
// What we do instead — the log IS the source of truth
await db.domainEvents.insert({
aggregate_id: request.id,
type: 'ApprovalGranted',
payload: {
approver_id: userId,
authority_basis: 'role:finance_director:limit:500000',
org_graph_version: currentOrgVersion, // snapshot of structure at approval time
budget_pool_snapshot: { available_before: 200000, reserved: 150000 },
},
occurred_at: new Date(),
sequence: nextSequence,
});
The current state of any request is derived by replaying its event log. This means you can answer "what did this request look like at any point in its lifecycle" — not just "what is it now" — which is the actual question in a financial audit.
What This Pattern Costs You
Three real trade-offs worth being honest about:
Graph consistency is a continuous problem. HR systems, ERPs, and your procurement platform all maintain their own opinion about who reports to whom. Unless you build reconciliation jobs and assign ownership of the graph's accuracy to a specific person or system, it drifts. A routing rule that's theoretically correct but applied against a stale graph is worse than useless — it's confident and wrong.
Guard-condition evaluation at request time is slower than a cached next_approver_id. At hundreds of thousands of requests per year, the recursive CTE needs proper indexing ((employee_id, valid_at, valid_until) at minimum) and result caching where the org structure is stable. We cache the resolved approval chain per (requester_id, cost_center_id, category_id, amount_bracket) with a TTL that expires on any org graph mutation event.
Flexibility is also a configuration risk. A hardcoded workflow has exactly one failure mode: it's wrong. A dynamically-generated one can be wrong in as many ways as the org graph can be misconfigured. The governance process around graph mutations matters as much as the graph design.
The One-Liner Summary
Treat your organization's structure as a versioned, queryable graph. Derive approval routing from it at runtime. Log every state transition as an immutable domain event. Your workflows will survive every reorg without a deployment, and your audit trail will be correct by construction rather than by discipline.
Built something similar? Hit a different wall with this pattern? Comments open — genuinely curious what breaks at the edges in other orgs.
Natalie Eksi is the CEO and Co-Founder of APSentra, a source-to-pay platform processing procurement workflows across multi-entity enterprise structures. She holds a Ph.D. candidacy in supply chain management and the CSCP certification, and focuses on the intersection of organizational modeling, financial process automation, and audit-ready system design. She speaks at industry conferences on procurement architecture and digitization.
LinkedIn
Top comments (0)