📚 essay
604 words
3 minutes

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_ratio
  • overtime = base * 1.5 * overtime_hours
  • income_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:

  1. Configuration, not code. Store the formula text + dependencies in the database. Users change rules via UI.
  2. 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 vals map. 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.

Designing a graph-based payroll engine
https://fuwari.vercel.app/posts/payroll-dag-engine/
Author
Thomas Engineer
Published at
2026-04-18
License
CC BY-NC-SA 4.0