Designing a payroll engine as a resolver graph
Why mutating passes make payroll bugs expensive to trace, and how modelling a run as named immutable slots buys parallelism and traceability at the same time.
The problem with sequential passes
Payroll engines tend to start the same way: a sequence of imperative passes. Fetch the inputs, compute base pay, apply overtime, apply deductions, apply tax, apply benefits, save. Each pass mutates a shared record.
It works, and then at some point it stops working. In my experience the thing that breaks first is not compute time — it is traceability.
Mutation hides causality
When a payslip looks wrong, someone has to walk back through every pass to find which one introduced the bad number. That investigation is much harder than it sounds, because mutation destroys the evidence you need:
- If pass three reads a wrong value, you cannot tell whether pass two wrote it, or pass one wrote it and pass two failed to overwrite it. The final state looks identical either way.
- Reproducing the bug means re-running the whole pipeline, because there is no intermediate value you can freeze and inspect.
There is a second, quieter cost. Passes that have no real dependency on each other still run sequentially, because the shared mutable record forces an order that the domain never asked for.
Modelling the run as a graph
The shape I have landed on treats a payroll run as a pure function from inputs to a resolved record, and the pipeline as a graph of named resolvers, each publishing to a typed slot.
inputs ─┐
├─► baseResolver ──► slot:base
├─► overtimeResolver ──► slot:overtime
├─► leaveResolver ──► slot:leave
slot:base ──┐
slot:overtime ──┼─► taxResolver ──► slot:tax
slot:leave ──┘Three properties follow from this:
- Each resolver declares its inputs. It does not reach into a shared object; it asks the engine for
slot:base, and suspends until that slot is published. The dependency graph becomes explicit and can be topologically sorted. - Slots are immutable once written. No resolver can trample another's output. Two resolvers claiming the same slot is a hard error at registration time, not a production surprise.
- Independent resolvers run in parallel. The engine dispatches anything whose inputs are ready, instead of following an order the data structure imposed.
Why streams fit this shape
Reactive streams are the wrong tool for most business logic, and people are right to be sceptical. This is one of the cases where they fit, for a specific reason: the engine is a fan-out / fan-in computation over a stream of changes.
When someone corrects a single input — an overtime override, a late expense claim — you do not want to recompute the entire batch. You want to invalidate exactly the slots downstream of that input, recompute those, and propagate. Model slots as multicast subjects and resolvers as operators subscribing to their declared inputs, and you get that behaviour without writing an invalidation tracker by hand.
The trap is hot versus cold. Each slot needs to be hot: multicast, latest value wins. Each resolver is cold until the engine subscribes it. Confusing the two gives you either a replay per record, which is catastrophic and obvious, or a stale read, which is worse because it is silent.
What a resolver looks like
A resolver is a pure function with its dependencies declared as data:
@Injectable()
export class OvertimeResolver implements Resolver<OvertimeInputs, Money> {
readonly slot = "overtime" as const;
readonly inputs = ["base", "shifts", "policy"] as const;
resolve({ base, shifts, policy }: OvertimeInputs): Money {
if (!policy.enabled) return Money.zero(base.currency);
const hours = shifts.reduce(
(total, shift) => total + Math.max(0, shift.hours - policy.threshold),
0,
);
return base.rate.times(hours).times(policy.rate);
}
}No database, no clock, no auth context. Inputs in, money out. Every bug becomes reproducible from a frozen inputs object, which means it becomes a unit test rather than an investigation.
What I would set up from day one
Version your slot shapes. Structural typing will happily let downstream resolvers keep reading an old shape after you extend it, and nothing will complain until the numbers are wrong. Brand each slot with a version literal and bump it when the shape changes.
Build the replay tool early. A small CLI that loads a frozen run from JSON, executes the engine, and diffs against the expected output pays for itself quickly. Reproducing a calculation bug should not require standing up the whole application.
The part that actually mattered
Not the framework, and not the graph. The move that mattered was going from mutating shared state to publishing to named slots. Once causality is cheap to recover, parallelism, traceability, and partial recomputation all follow.
If you are building a domain engine where the people using it care more about why a number is what it is than how fast it arrives, optimise for that first. Everything else is downstream.