Designing a graph-based payroll engine
The problem
Payroll looks simple until you meet a real enterprise ruleset. A single employee’s pay is a pile of interdependent line items:
base = rank.base_salary * effort_ratioovertime = base * 1.5 * overtime_hoursincome_tax = (base + overtime + bonuses) * tax_bracket_fn(...)net = base + overtime + bonuses - income_tax - social_insurance - ...
Each component can reference any other. HR changes formulas constantly. Worse: some companies have custom components — a “Tet bonus” formula that only exists for that client.
Hard-coding the calculation order meant every new rule was a code deploy. Lead time for a new payroll component was weeks.
The insight
These formulas form a directed acyclic graph. A component is a node. “Uses the value of X” is an edge from X to the current component. The order you must compute them in is a topological sort of that graph.
Once you see it that way, two things fall out:
- Configuration, not code. Store the formula text + dependencies in the database. Users change rules via UI.
- Parallelism for free. Nodes at the same topological level have no dependencies between them — compute them concurrently.
Kahn’s algorithm
Kahn’s is the classic O(V + E) topological sort. You keep a queue of nodes with no remaining incoming edges:
type Node struct { ID string Formula string // "base * 1.5 * overtime_hours" DependsOn []string}
func topoLevels(nodes []*Node) [][]*Node { inDeg := make(map[string]int, len(nodes)) byID := make(map[string]*Node, len(nodes)) children := make(map[string][]*Node, len(nodes))
for _, n := range nodes { byID[n.ID] = n inDeg[n.ID] = len(n.DependsOn) for _, dep := range n.DependsOn { children[dep] = append(children[dep], n) } }
var levels [][]*Node ready := make([]*Node, 0) for _, n := range nodes { if inDeg[n.ID] == 0 { ready = append(ready, n) } }
for len(ready) > 0 { level := ready levels = append(levels, level) ready = nil for _, n := range level { for _, c := range children[n.ID] { inDeg[c.ID]-- if inDeg[c.ID] == 0 { ready = append(ready, c) } } } } return levels}If the graph has a cycle (A depends on B which depends on A), some nodes will never hit in-degree 0 — detect it by comparing total processed nodes to the input length. We reject the ruleset at save time rather than at compute time.
Parallel calculation per level
The critical trick: within a level, nodes are independent. For 6,000 employees Ă— ~30 components each, the naive sequential version took ~5 minutes. Level-parallel with sync.WaitGroup and a bounded goroutine pool collapsed it to ~30 seconds.
func computeEmployee(emp *Employee, levels [][]*Node) (map[string]float64, error) { vals := make(map[string]float64) var mu sync.Mutex
for _, level := range levels { var wg sync.WaitGroup errs := make(chan error, len(level)) for _, node := range level { wg.Add(1) go func(n *Node) { defer wg.Done() v, err := evaluateFormula(n.Formula, emp, vals) if err != nil { errs <- fmt.Errorf("%s: %w", n.ID, err) return } mu.Lock() vals[n.ID] = v mu.Unlock() }(node) } wg.Wait() close(errs) for err := range errs { if err != nil { return nil, err } } } return vals, nil}In production we cap the pool — unlimited goroutines spawn thousands for a big company, thrashing the CPU and killing the formula interpreter cache.
What I’d do differently
- Cache compiled formulas. Parsing the expression per employee per component is 80% of the time. The DAG structure is shared across the whole company.
- Structural sharing for the
valsmap. For identical employees (same rank, same contract type), most components have identical values — don’t recompute. - Persistent levels. The level structure rarely changes. Recompute only on ruleset update.
The whole thing is ~800 lines of Go. Business users have added 40+ custom components in the last year. Zero deploys.