What Lookups Are
Every payroll calculation depends on numbers that come from outside the organization — income tax brackets published by the tax authority, social security ceilings from the labor ministry, minimum wages enacted by parliament, employer contribution rates defined in statutory instruments. These numbers are not business logic. They change on a legislative schedule that no software vendor controls. The question is where to put them.
In the Payroll Engine, these values live in Lookups — named key-value tables stored in regulation JSON. Each Lookup entry has a key (typically the year or a bracket threshold) and a valueObject containing the actual parameters as a JSON object. A wage type or Custom Action reads the Lookup at runtime, resolved by the payroll period’s context.
Here is a simple Lookup containing social security contribution rates, keyed by year:
{
"name": "ContributionParameter",
"values": [
{
"key": "2025",
"valueObject": {
"KvAnRate": 0.073,
"KvAgRate": 0.073,
"RvRate": 0.093,
"AvRate": 0.013,
"BbgKvPv": 66150
}
},
{
"key": "2026",
"valueObject": {
"KvAnRate": 0.081,
"KvAgRate": 0.081,
"RvRate": 0.093,
"AvRate": 0.013,
"BbgKvPv": 69300
}
}
]
}
Each entry is a self-contained snapshot of the parameters for that key. When a wage type running for a January 2026 payroll period requests ContributionParameter with key 2026, it receives the full object — all rates and thresholds for that year. The calculation logic does not know or care what the rates are. It reads them from the Lookup, applies them, and produces a result. When the rates change for 2027, only the Lookup data is updated — not the wage type that reads it.
Separation principle: Lookups separate what to calculate (wage type logic) from which numbers to use (statutory parameters). This separation means annual compliance updates are data changes, not code deployments. The calculation logic remains stable across years.
Lookups can contain any JSON-serializable data: rates, thresholds, ceilings, allowance amounts, bracket definitions, per-diem rates, mileage reimbursements. A single regulation might define dozens of Lookups, each covering a different parameter domain. The Cases and CaseFields model captures employee-specific data; Lookups capture the statutory constants that apply to everyone.
Threshold vs. Progressive: Two Modes, Two Results
Lookups support two resolution modes that produce fundamentally different results from the same data. Understanding the distinction is critical for every regulation developer, because choosing the wrong mode means silently wrong tax or contribution calculations.
Threshold mode finds the bracket that matches the input value and returns the factor for that bracket only. If an income of 40,000 falls into the 25% bracket, the result is 25% of 40,000 = 10,000. The lower brackets are ignored entirely.
Progressive mode distributes the input value across all brackets sequentially, applying each bracket’s rate to the portion of the value that falls within that bracket. An income of 40,000 might be taxed at 0% for the first 10,000, 15% for the next 20,000, and 25% for the remaining 10,000 — producing a result of 0 + 3,000 + 2,500 = 5,500. Same data, vastly different result.
Here is a tax bracket Lookup that supports both modes:
{
"name": "TaxBrackets",
"values": [
{
"key": "2026",
"rangeValue": 0,
"valueObject": { "Rate": 0.0 }
},
{
"key": "2026",
"rangeValue": 11784,
"valueObject": { "Rate": 0.14 }
},
{
"key": "2026",
"rangeValue": 17006,
"valueObject": { "Rate": 0.2397 }
},
{
"key": "2026",
"rangeValue": 66761,
"valueObject": { "Rate": 0.42 }
},
{
"key": "2026",
"rangeValue": 277826,
"valueObject": { "Rate": 0.45 }
}
]
}
Tip: The first entry in any bracket-based Lookup must have rangeValue: 0. Missing the zero bracket causes the engine to skip values below the first defined threshold, producing incorrect results for low-income employees. This is the most common Lookup authoring error.
When the wage type reads this Lookup in Threshold mode, it finds the single bracket that contains the taxable income and applies that bracket’s rate. When it reads in Progressive mode, it walks through all brackets from the bottom, applying each rate to the portion of income within that bracket’s range. Income tax systems are almost always progressive. Flat contribution rates (social security, Solidaritätszuschlag surcharges) use Threshold mode.
| Mode | Behavior | Typical Use | Example |
|---|---|---|---|
| Threshold | Returns the factor for the matching bracket only | Flat-rate contributions, surcharges, single-bracket lookups | Social security rate: income in bracket → apply that rate to full amount |
| Progressive | Distributes value across all brackets sequentially | Progressive income tax, graduated social contributions | Income tax: each portion of income taxed at its bracket’s rate |
The mode is specified by the consuming wage type, not by the Lookup definition. The same Lookup data can be read in either mode, depending on the caller’s intent. This flexibility is deliberate — some countries use the same bracket table for both progressive tax and threshold-based surcharge calculations.
Reading Lookups in Wage Types
Lookups are only useful if wage types can read them efficiently. The Payroll Engine provides two access paths: the No-Code ^# token for simple lookups, and the C# GetLookup / GetLookupField methods for Custom Actions that need full programmatic control.
No-Code: The ^# Token
In a No-Code wage type’s valueActions, the ^# token reads a single field from a Lookup entry:
"valueActions": [
"^#ContributionParameter(PeriodStartYear, 'KvAnRate')"
]
This reads the KvAnRate field from the ContributionParameter Lookup for the entry whose key matches the current period’s start year. The result is a decimal value that the wage type can use directly. For simple rate lookups — “what is the KV employee rate for this year?” — the ^# token is sufficient and keeps the wage type fully No-Code.
Limitation: The ^# token fails silently when used as an argument to arithmetic operators or inside complex expressions. "^$1000 * ^#Rates(PeriodStartYear, 'Rate')" will not produce the expected result. For any arithmetic involving a Lookup value, implement a Custom Action and use GetLookup<T>() in C#.
Custom Actions: GetLookup and GetLookupField
When calculation logic needs the full Lookup object or must perform arithmetic with Lookup values, a Custom Action provides direct access:
// Full object — deserializes entire JSON valueObject
var param = GetLookup<ContributionParam>(
"ContributionParameter", PeriodStart.Year);
if (param == null) return ActionValue.Cancel;
var kvBeitrag = brutto * param.KvAnRate;
var rvBeitrag = Math.Min(brutto, param.BbgKvPv) * param.RvRate;
return Math.Round(kvBeitrag + rvBeitrag, 2);
// Single field — extracts one property without full deserialization
var rate = GetLookupField<decimal>(
"ContributionParameter", PeriodStart.Year, "KvAnRate");
return Math.Round(brutto * rate, 2);
GetLookup<T> deserializes the entire valueObject into a typed C# object. This is efficient when the calculation needs multiple fields from the same entry — one lookup call retrieves all values. GetLookupField<T> extracts a single field by name, which is more concise when only one parameter is needed.
Both methods resolve the Lookup through the regulation layer hierarchy. If a provider overlay redefines a Lookup entry (same name, same key), the override takes precedence — exactly like the layer model works for wage types and Cases. This means a provider can override a single statutory parameter without forking the entire data satellite.
The Data Satellite Pattern
A single regulation JSON file could contain both the calculation logic (wage types, Cases, collectors) and the statutory parameter data (Lookups). But combining them creates a coupling problem: updating a tax rate for 2027 means modifying the same file that contains the tax calculation logic. The modification triggers a full test cycle of the calculation, even though the logic has not changed. For a multi-country provider covering eleven jurisdictions, this coupling multiplies into dozens of unnecessary test-and-deploy cycles per year.
The data satellite pattern solves this by separating statutory parameter data into its own regulation. A data satellite is a regulation that contains only Lookups, declares a baseRegulations dependency on the parent country regulation, and carries a validFrom date marking its effective period. The German regulation, for example, splits into:
| Regulation | Contains | validFrom |
|---|---|---|
DE.Entgeltabrechnung |
Wage types, Cases, collectors, scripts | — |
DE.Entgeltabrechnung.Data.LSt.2025 |
Income tax parameters (brackets, allowances, deductions) | 2025-01-01 |
DE.Entgeltabrechnung.Data.LSt.2026 |
Income tax parameters (brackets, allowances, deductions) | 2026-01-01 |
DE.Entgeltabrechnung.Data.SV.2025 |
Social security rates, ceilings, thresholds | 2025-01-01 |
DE.Entgeltabrechnung.Data.SV.2026 |
Social security rates, ceilings, thresholds | 2026-01-01 |
When a payroll runs for January 2026, the engine resolves Lookups through the layer chain: it finds the Data.LSt.2026 satellite (validFrom 2026-01-01 ≤ period start) and uses its tax brackets. When a retro correction re-runs January 2025, it automatically resolves to Data.LSt.2025 — the 2025 brackets apply, producing historically correct results without any retro-specific code.
The satellite declares its dependency explicitly:
{
"name": "DE.Entgeltabrechnung.Data.LSt.2026",
"namespace": "DE",
"baseRegulations": ["DE.Entgeltabrechnung"],
"validFrom": "2026-01-01",
"lookups": [
{
"name": "LStParameter",
"values": [
{
"key": "2026",
"valueObject": {
"Grundfreibetrag": 12096,
"EingangssteuersatzY": 0.14,
"SpitzensteuersatzS4": 0.42,
"Reichensteuer": 0.45
}
}
]
}
]
}
Why separation matters: A compliance update is now a data update to a satellite regulation, not a code release to the main regulation. The calculation wage types are not modified, not rebuilt, not retested for logic correctness. The only verification needed is that the new parameter values are correct — which is a data verification task, not a software testing task.
Building Your Own Lookup Tables
Data satellites are not limited to country regulations. Any provider can create their own satellite for parameters that change annually or by policy — travel reimbursement rates, per-diem allowances, industry-specific contribution supplements, shift differential tables, regional cost-of-living adjustments.
The structure is identical to a country data satellite. The provider regulation declares its own namespace, points to its base regulation, and carries a validFrom date:
{
"name": "ACME.Data.Travel.2026",
"namespace": "ACME",
"baseRegulations": ["ACME.Regulation"],
"validFrom": "2026-01-01",
"lookups": [
{
"name": "TravelRates",
"values": [
{
"key": "2026",
"valueObject": {
"KmRate": 0.30,
"PerDiemDomestic": 28.00,
"PerDiemInternational": 47.00,
"MaxMonthlyParking": 150.00
}
}
]
}
]
}
A provider wage type reads this Lookup exactly like a country wage type reads statutory parameters:
"valueActions": [
"^#TravelRates(PeriodStartYear, 'KmRate')"
]
Or, in a Custom Action that needs arithmetic:
var rates = GetLookup<TravelRateParam>("TravelRates", PeriodStart.Year);
if (rates == null) return ActionValue.Cancel;
var km = (decimal)GetCaseValue<decimal>("TravelKm");
return Math.Round(km * rates.KmRate * workingDays, 2);
The annual update cycle follows a predictable pattern: each December, the provider reviews their rate tables, creates a new ACME.Data.Travel.2027 satellite with updated values, imports it into the system, and the January payroll automatically picks up the new rates. The 2026 satellite remains in place for any retro corrections that reach back into the prior year. No wage type is modified. No calculation logic is retested.
Tip: Keep each data satellite focused on one parameter domain. A Data.Travel satellite for travel rates, a Data.Shifts satellite for shift differentials, a Data.PerDiem satellite for per-diem rates. This granularity means updating travel rates does not require touching shift parameters. It also makes the annual review process easier — each satellite can be verified independently against its own source documents.
Provider data satellites participate in the same layer resolution as country data satellites. If the provider’s payroll references both DE.Entgeltabrechnung (with its statutory data satellites) and ACME.Regulation (with its provider data satellites), all Lookups are available to all wage types through the combined layer hierarchy. A provider wage type can read both a statutory contribution rate and a provider-specific travel rate in the same calculation.
The Setup Pipeline: Four Files in Sync
A data satellite is a regulation JSON file. But importing it into a running Payroll Engine instance and making it available to tests requires more than just the JSON. Four files must be updated in lockstep whenever a data satellite is added or modified. Missing any one of them causes failures that range from obvious import errors to silent calculation errors that only surface during payroll runs.
| File | Purpose | What Happens If Missing |
|---|---|---|
regulation-package.json |
NuGet packaging — lists files included in the deployment package | Satellite JSON is not included in the NuGet package; deployment to other environments fails |
Setup.pecmd |
Runtime import — the pecmd script that actually loads the regulation into the backend |
Satellite is packaged but never imported; wage types cannot resolve its Lookups at runtime |
Setup.Test.pecmd |
Test pipeline — imports data satellites and test fixtures for integration testing | Tests run without the satellite’s data; Lookup calls return null; tests pass with wrong values or are silently skipped |
XX.Test.Setup.json |
Payroll layer configuration — lists every regulation the test payroll can access | Satellite is imported but not in the payroll’s layer list; GetLookup returns null because the payroll cannot “see” the regulation |
The most insidious failure mode is the last one. When a data satellite is imported into the system (Setup.Test.pecmd is correct) but not listed in the test payroll’s layers (XX.Test.Setup.json is missing the entry), the regulation exists in the database but is invisible to the payroll. Every GetLookup call returns null. If the wage type has a null check and returns zero, the test passes — with a result of zero instead of the correct contribution amount. The test is green, the payslip is wrong.
The Setup.pecmd file is the critical runtime artifact. It contains the RegulationImport commands that load each JSON file into the backend:
// Setup.pecmd (excerpt)
RegulationImport DE.Entgeltabrechnung.json
RegulationImport Data/Data.LSt.2025.json
RegulationImport Data/Data.LSt.2026.json
RegulationImport Data/Data.SV.2025.json
RegulationImport Data/Data.SV.2026.json
The XX.Test.Setup.json file defines the payroll object with its layer array:
{
"payrolls": [
{
"name": "DE.Payroll",
"layers": [
{ "regulationName": "DE.Entgeltabrechnung" },
{ "regulationName": "DE.Entgeltabrechnung.Data.LSt.2025" },
{ "regulationName": "DE.Entgeltabrechnung.Data.LSt.2026" },
{ "regulationName": "DE.Entgeltabrechnung.Data.SV.2025" },
{ "regulationName": "DE.Entgeltabrechnung.Data.SV.2026" }
]
}
]
}
Checklist for every new data satellite (non-negotiable):
1. New .json file created with Lookup data, baseRegulations, and validFrom.
2. regulation-package.json updated — new file listed in installFiles.
3. Setup.pecmd updated — RegulationImport line added for the new JSON.
4. Setup.Test.pecmd updated — import line added for the test pipeline.
5. XX.Test.Setup.json updated — new regulation added to payroll layers array.
This four-file synchronization is the most common source of “it works locally but fails in CI” issues in regulation development. A developer creates the data satellite JSON, tests it by importing manually in their local environment, sees correct results, and commits — forgetting to update the packaging and pipeline files. The CI pipeline runs tests with the old layer configuration, and either fails with cryptic null-reference errors or, worse, passes with silently wrong results.
The discipline is straightforward: treat these four files as a single atomic unit. When you add a data satellite, open all four files, make all four changes, and commit them together. The test-driven compliance approach catches most synchronization errors, but only if the test actually exercises the Lookup values from the new satellite — which requires the test payroll to have the satellite in its layers.
Externalize your parameters
See how versioned lookups and data satellites keep your regulation logic stable while statutory values change annually.
Get in Touch →