Why Layers Instead of Forks
The most common approach to localizing or extending a payroll system is forking. You take the vendor’s country regulation, copy it into your own repository, and start adding your company-specific wage types, collectors, and case fields. Within a few months you have a working system. Within a year you have a compliance nightmare.
The problem is structural: when the statutory regulation changes — new tax brackets, updated contribution ceilings, revised thresholds — you need to merge those changes into your fork. But your fork has diverged. You have modified collectors, added fields to shared cases, perhaps adjusted rounding logic to match a client requirement. Every merge becomes a risk assessment exercise where a missed conflict can produce incorrect payslips for an entire population.
This is compliance drift: the gradual divergence between your running system and the statutory regulation it claims to implement. It compounds over time, and it is almost invisible until an audit surfaces it.
Payroll Engine solves this by making every regulation extension itself a regulation. Provider additions, industry specifics, and tenant overrides are all self-contained regulation JSON packages that declare their parent and overlay their objects at runtime. The country regulation remains untouched — a read-only statutory foundation that receives updates independently of anything built on top of it.
The Four Levels
The regulation stack in Payroll Engine consists of four distinct levels. Each level has a defined scope, a designated owner, and a characteristic update cycle. Objects at higher levels can read objects from lower levels but never modify them.
| Level | Name | Scope | Owner | Update Cycle | WT Range |
|---|---|---|---|---|---|
| L1 | Country | Statutory — all employers in a jurisdiction | Regulation vendor | Annual (legislative cycle) | 1–7999 |
| L2 | Provider Base | Shared across all provider clients | Payroll bureau / EOR | Per release | 9100–9199 |
| L3 | Industry | Sector-specific (CAO, Convenio, Tarifvertrag) | Industry consultant | Per collective agreement | 9200–9299 |
| L4 | Tenant | Employer-specific | Client / implementation team | Ad-hoc | 9300–9399 |
Each level is a complete regulation in its own right — it has its own namespace, its own wage types, its own cases and lookups. The country regulation (L1) contains all statutory calculations: income tax, social security contributions, statutory deductions. The provider regulation (L2) adds shared wage types that all clients of that provider need — perhaps a standardized bonus structure, a company car scheme, or a reporting wage type. The industry regulation (L3) layers sector-specific logic — a Dutch CAO, a Spanish convenio colectivo, or a German Tarifvertrag. Finally, the tenant regulation (L4) captures employer-specific additions that no one else needs.
Not every deployment uses all four levels. A simple single-employer setup might use only L1 + L4. A multi-client bureau typically uses L1 + L2 + L4. The full stack including L3 is common in countries with strong collective bargaining frameworks.
baseRegulations: Design-Time Inheritance
The relationship between a regulation and its parent is declared via the baseRegulations array. This is a design-time declaration: it tells Payroll Engine which regulation(s) this overlay extends, enabling the system to resolve object references across the inheritance chain.
{
"name": "ACME.Provider",
"namespace": "ACME",
"description": "ACME provider overlay for DE.Entgeltabrechnung",
"baseRegulations": [
"DE.Entgeltabrechnung"
],
"cases": [...],
"wageTypes": [...],
"collectors": [...]
}
With this declaration in place, everything defined in DE.Entgeltabrechnung is visible to the ACME regulation at design time. Scripts in the ACME regulation can call GetCaseValue("DE.Salary") and it resolves. Wage types in ACME can reference collectors defined in DE. Lookups defined in DE are accessible via GetLookup.
Critically, the overlay cannot modify objects it inherits. It can read the country regulation’s gross income collector, but it cannot change its threshold logic. It can read a statutory lookup table, but it cannot insert rows. This immutability guarantee is what makes independent updates possible — the country regulation vendor can ship a new version without any coordination with downstream overlays.
The baseRegulations mechanism also enables tooling support: the regulation editor can offer autocompletion for inherited objects, the test framework can resolve cross-regulation references, and the import pipeline validates that declared bases exist before accepting an overlay.
Payroll Layers: Runtime Composition
While baseRegulations establishes design-time visibility, the layers array on a payroll definition controls what actually executes at runtime. This is where composition happens — you wire together the regulations that should participate in a specific payroll calculation.
{
"name": "ACME Monthly Payroll",
"layers": [
{ "level": 1, "priority": 1, "regulationName": "DE.Entgeltabrechnung" },
{ "level": 1, "priority": 2, "regulationName": "Data.LSt.2026" },
{ "level": 1, "priority": 3, "regulationName": "Data.SV.2026" },
{ "level": 2, "priority": 1, "regulationName": "ACME.Provider" },
{ "level": 4, "priority": 1, "regulationName": "ACME.ClientAlpha" }
]
}
The level field determines where in the stack a regulation sits. The priority field resolves conflicts within the same level — a higher priority wins when two regulations at the same level define an object with the same name. This is how data satellites work: Data.LSt.2026 and Data.SV.2026 both sit at level 1, but with higher priority than the base country regulation, so their annual lookup values override any defaults.
At payrun time, the engine builds a composite view of all layers. Wage types are collected from all levels, ordered by number, and executed in sequence. Collectors aggregate across layers. Case fields from all regulations are available to scripts. The result is a single coherent calculation that spans the entire stack without any layer needing to know the internal details of another.
For a deeper exploration of how this composition model works — including override keys, collector group inheritance, and layer conflict resolution — see The Composable Regulation Model.
Namespace Isolation
Every regulation declares a namespace: "namespace": "DE" for the country regulation, "namespace": "ACME" for a provider regulation. This namespace is the isolation boundary that prevents name collisions across layers.
The critical rule: within a regulation JSON file, objects are defined without their namespace prefix. The prefix is applied automatically by the import engine. When referencing objects in the same namespace, you use the short name. When referencing objects in a foreign namespace, you use the fully qualified name.
| Context | Same Namespace | Foreign Namespace |
|---|---|---|
JSON definition (name field) |
"Salary" — no prefix |
— |
No-Code token ^^ |
^^Salary |
^^DE.Salary |
| Collector JSON | "GrossIncome" |
"DE.Gesamtbrutto" |
| C# script | "Salary" |
"DE.Salary" |
| Action parameter literal | 'ACME.Salary' — always FQ |
'DE.Salary' |
This means a provider regulation with namespace ACME can define a wage type called AnnualBonus. It will never collide with a country wage type — even if the country regulation also had a AnnualBonus, they would be stored as ACME.AnnualBonus and DE.AnnualBonus respectively. The engine resolves the correct one based on context.
One important exception: action parameter literals are always fully qualified, even within the same namespace. This is because action parameters are passed verbatim to the runtime API without namespace resolution. Writing 'ACME.Salary' in an ACME action is correct; writing just 'Salary' would fail at runtime.
WageType Number Ranges
Wage types in Payroll Engine execute in numeric order. This is not incidental — it is the primary mechanism for controlling calculation sequence. A wage type that needs the result of another wage type must have a higher number. The numbering ranges ensure that country calculations (taxes, contributions) always complete before provider or tenant additions that depend on their results.
| Range | Level | Purpose |
|---|---|---|
| 1–99 | Country | Input wage types (base salary, hourly rate, pro-rata factor) |
| 1000–7999 | Country | Statutory calculations (tax, social security, deductions, net pay) |
| 9100–9199 | Provider | Provider-wide additions (company car, standard bonuses, reporting) |
| 9200–9299 | Industry | Sector-specific (CAO allowances, Tarifvertrag supplements) |
| 9300–9399 | Tenant | Employer-specific (custom bonuses, local allowances) |
The numeric ordering has a practical consequence: a provider wage type at 9100 can safely reference the result of country wage type 5100 (Lohnsteuer) because 5100 has already been calculated by the time 9100 executes. This is why the ranges are spaced as they are — country calculations occupy the lower numbers, giving overlays access to all statutory results.
Within each range, the regulation developer chooses specific numbers. The convention is to leave gaps for future additions — numbering 9100, 9110, 9120 rather than 9100, 9101, 9102 — so that new wage types can be inserted in the correct sequence position without renumbering existing ones.
^$ tokens or the WageType[] indexer. References to higher-numbered wage types return zero (they haven’t executed yet).
For provider regulation developers, this means you never need to worry about when your wage type runs relative to the country regulation — the numbering guarantees it. You focus on what your wage type calculates and which country results it reads.
Start building on the layer model
Explore how the four-layer regulation stack works for your country coverage — or get in touch to discuss your first overlay.
Get in Touch →