Skip to content

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.1DE.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.


See Also