Best Practices: Design
Regulation Design
Regulations carry rules, not data
A regulation describes how payroll is calculated — it does not contain the values it operates on. Rates, tax tables, and employee parameters are data. They belong in cases and lookups, populated at runtime or via import. A regulation that embeds hardcoded values must be changed every time those values change; a regulation that reads from cases and lookups stays stable for years.
In practice: declare lookups with values: [] and fill them via
LookupTextImport. Store employee parameters (tax table number, employment
level, birth date) as case fields. The regulation survives an annual
table update or a salary change without modification.
Assign a namespace to every regulation
Without a namespace, object names from different regulations in the same payroll can collide silently. The namespace is automatically prefixed to every object identifier and keeps cross-regulation references unambiguous.
In practice: follow the layer hierarchy — DE for country,
DE.Acme for tenant. The namespace stays stable across versions
(DE.Acme 2025.1 → DE.Acme 2026.1), so all script references remain
valid without change. In scripts, always use the fully qualified name
("DE.Acme.Bonus"); in actions, the short form (^$Bonus) is allowed
within the same namespace. See Regulation Namespace.
Structure follows separation of concerns
A payroll regulation grows over time. The structure chosen at the start determines how much effort future changes require. Elements that belong together should be grouped; elements that could change independently should be separated.
In practice: split import files by concern — regulation structure, case values, payrun jobs — so each can be refreshed without touching the others. In multi-country setups, extract shared logic into a base regulation and let country layers add only what differs. When retro corrections are needed, separate the wage types that recalculate from those that collect the diff — mixing them produces wrong results.
Data Modeling
Scope data at the right level
Every value in the system has a natural scope: some are legislated for everyone, some apply per employer, some vary per employee. Capturing a value at the wrong level either forces duplication or prevents legitimate variation.
In practice: use National cases for statutory rates, Company
cases for employer policy, Employee cases for individual data.
For multi-country payrolls, valueScope: Global allows one canonical
salary entry to be shared across all countries — while employment level
stays per-division because it legitimately differs per country contract.
Lookups store regulation data, cases store operational data
Lookups and cases serve different purposes. Lookups contain stable regulation data — tax tables, statutory rates, reference values defined by law or policy that change infrequently. Cases contain operational data — values entered by HR for specific employees or the company at a specific point in time.
In practice: use lookups for reference tables populated via
LookupTextImport. Use Company cases for annually-updated statutory
values (e.g. minimum wage rate) that HR enters once per year. Do not
force lookups where a Company case is the better semantic fit.
Time is a first-class dimension
Payroll data is always anchored in time. The same salary may apply differently in January than in March; a bonus entered in March may belong to January; a rate change effective from February affects all subsequent periods. Ignoring the temporal model leads to corrections that are either missed or applied to the wrong period.
In practice: choose the time type that matches the nature of each
value — Timeless for permanent attributes, Period for open-ended
values, CalendarPeriod for month-bound values, Moment for one-time
events on a specific date. In retro scenarios, ensure baseline results
are stored so that corrections can be computed as deltas against what
was actually paid.
Wage Type Design
Number wage types in calculation order
A payrun processes wage types in ascending numeric order. Every expression
that reads a previously computed result via WageType[n] or ^$Name
depends on that wage type having already run.
In practice: assign numbers so that gross income components (100–199) always precede deductions (200–299). Reserve a consistent number range per concern across the entire regulation — the structure becomes self-documenting and prevents ordering bugs as the regulation grows.
Intermediate wage types do not belong to a collector
Not every wage type represents a payable amount. Reference values used only as inputs to subsequent wage types — a minimum wage base, a pro-rata denominator, a threshold — should carry no collector assignment.
In practice: omit the collectors list on intermediate wage types.
The engine still stores the result and makes it accessible via ^$Name
within the same payrun, and queryable via the API for audit purposes.
Adding it to a collector would double-count the amount in the collector
total and distort reporting.
Use condition guards to suppress zero results cleanly
Many wage types are only relevant in certain situations — a top-up only when pay is below a threshold, a bonus only when a value is present, an adjustment only in specific periods. Omitting a guard produces a zero result that pollutes reporting.
In practice: place ? <condition> as the first action in a
valueActions list. If the condition is false the engine skips all
subsequent actions and produces no result. Combine with
storeEmptyResults: true on the payrun job when a zero must be stored
explicitly for compliance queries.
Guard against null for optional case values
A case value that has not been entered for the current period is null, not zero. An expression that reads a null value without a guard throws at runtime.
In practice: check HasValue before using any case value that may
be absent in normal operation — one-time bonuses, optional supplements,
Moment-type entries. Return an explicit 0M when the value is absent.
retroPayMode=None suppresses retro sub-runs and consumes pending mutations
retroPayMode on a payrun job invocation controls whether the engine
creates retro sub-runs when it detects prior-period case mutations.
| Value | Behaviour |
|---|---|
ValueChange |
Default. Retro sub-runs are created for all prior periods where mutations have occurred since the last payrun. |
None |
Retro detection is skipped. No sub-runs are created. |
The critical side-effect of retroPayMode=None is that detected mutations
are marked as processed even though no sub-run was executed. A subsequent
ValueChange job will not see those same mutations again.
In practice: do not use retroPayMode=None as a temporary workaround
when retro calculation is undesirable for a single period — it silently
discards the pending corrections. Use it only when the regulation explicitly
intends to skip retro for a specific job (e.g. initial data load, forecast
preview, or correction runs where the retro delta is intentionally zeroed).
Collector Design
A wage type can contribute to multiple collectors simultaneously
A wage type's collectors list accepts any number of collector names. Each
collector receives the full wage type value independently — there is no
sharing or splitting.
In practice: assign a wage type to all collectors it logically belongs
to. Common pattern: a base salary feeds a GrossWage collector for payslip
totals and a SocialBase collector for capped contribution bases. Bonus
wage types not subject to social contributions are assigned to GrossWage
only, leaving SocialBase unaffected.
maxResult and minResult cap and floor the accumulated collector total
maxResult and minResult are constraints on the final collector value
after all contributing wage types have been applied — not on individual
contributions.
In practice: use maxResult for statutory contribution ceilings (e.g.
maximum insurable earnings). Use minResult for guaranteed minimum bases
(e.g. minimum pension contribution base). Verify the boundary in both
directions with a test that passes through all three zones: below floor,
between floor and cap, and above cap.
negated inverts the collector result, not the wage type result
Setting negated: true on a collector causes the accumulated value to be
stored with its sign inverted. The contributing wage type always stores its
own result with the original sign.
In practice: use negated: true for deduction collectors where the sign
convention of the collector total must reflect a reduction (e.g. NetDeductions
should sum to a negative value). Do not negate the wage type expression itself
— that would produce a double negation if the collector is also negated.