Scoped params
When the same computed value appears in several places, name it once. A params object declares named subexpressions — on the rule set for every rule, or on one rule for itself — and { "param": "…" } references one anywhere an expression is valid. Params are still pure data: they never change what a rule can compute, only how it reads.
Declaring and referencing
{
"name": "Scoped params",
"params": {
"averageLine": {
"op": "divide",
"operands": [ { "field": "Order.Total" }, { "field": "Order.ItemCount" } ]
},
"rate": 0.1
},
"rules": [
{
"id": "pricey-basket",
"condition": {
"expression": { "param": "averageLine" },
"operator": "GreaterThan",
"value": 50
},
"actions": [
{ "type": "setOutput", "target": "Segment", "value": "premium" },
{ "type": "setOutput", "target": "AverageLinePrice", "value": { "param": "averageLine" } }
]
},
{
"id": "standard-discount",
"condition": { "field": "Order.Total", "operator": "GreaterThanOrEqual", "value": 100 },
"actions": [
{
"type": "setOutput",
"target": "Discount",
"value": {
"op": "multiply",
"operands": [ { "field": "Order.Total" }, { "param": "rate" } ]
}
}
]
},
{
"id": "vip-discount",
"params": { "rate": 0.2 },
"condition": { "field": "Customer.IsVip", "operator": "Equals", "value": true },
"actions": [
{
"type": "setOutput",
"target": "VipDiscount",
"value": {
"op": "multiply",
"operands": [ { "field": "Order.Total" }, { "param": "rate" } ]
}
}
]
}
]
}
Three things to see in that document:
averageLineis defined once and used twice inpricey-basket: as the condition's computed left-hand side, and as an action value.- A param can be a bare constant.
ratenames the number0.1, which gives the document one place to change it. - A rule's
paramsshadow the set's.vip-discountredefinesrateas0.2for itself; every other rule still sees0.1.
using RuleWright.Core;
using RuleWright.Execution;
using RuleWright.Json.SystemText;
LoadedRuleSet rules = engine.LoadRuleSet(File.ReadAllText("scoped-params.json"));
foreach (Checkout checkout in new[] { Facts.Vip(), Facts.Newcomer() })
{
RuleEvaluationResult result = engine.Evaluate(rules, checkout);
Console.WriteLine($"{checkout.Customer.Name}:");
foreach (KeyValuePair<string, object?> output in result.Outputs)
{
Console.WriteLine($" {output.Key,-17} = {output.Value ?? "null"}");
}
}
Output
Aroha:
Segment = premium
AverageLinePrice = 60
Discount = 24.0
VipDiscount = 48.0
Ben:
Where a reference is valid
A { "param": "…" } node can stand wherever an expression can: an action's value, a condition's expression, an operand inside another expression, a decision-table then cell, and inside another param's definition. Definitions may reference sibling and outer params in any order — but never in a cycle, which validation rejects with a JSON pointer at the offending definition.
One place is deliberately off-limits: inside a quantifier's per-element condition. A param's definition reads the root fact, while field paths there resolve against the element, so a reference would silently answer null. The validator rejects it instead.
Inlined at load
References are substituted when the document loads, exactly as a decision table expands into rules. The engine, the content hash and both execution paths never see a param: a param-authored document compiles to the same delegates — and the same cache entries — as its hand-inlined twin. Two consequences:
- Params are free. There is no per-evaluation lookup; a reference costs exactly what writing the expression inline costs.
- In C#, a param is just a shared instance. The domain model has no param node: reuse one immutable
ValueExpressionin several rules and you have written the same thing the parser produces. Build rules in C# shows the model.
Note
Unlike a variable, a param is not computed once per evaluation — each reference evaluates the expression where it stands. Expressions are pure and cheap, so the answers are identical; only very hot paths would notice, and a custom value function can memoize if one ever does.
Next
Collections, or the params-heavy alternative: a decision table.