Build rules in C#
The domain model behind the JSON is public and immutable. Build rules from it directly for tests, for code generation, or to translate rules from your own storage format, and load them without any JSON reader.
A rule in code
using RuleWright.Core;
using RuleWright.Execution;
var rule = new Rule(
id: "vip-discount",
condition: new ConditionGroup(LogicalOperator.And, new ConditionNode[]
{
new ConditionLeaf("Customer.Age", ConditionOperator.GreaterThanOrEqual, 18L),
new ConditionLeaf("Order.Total", ConditionOperator.GreaterThan, 100m),
}),
actions: new[]
{
new RuleAction(RuleAction.SetOutputType, "DiscountPercent", 10L),
new RuleAction(RuleAction.AddToOutputType, "Points",
new OperatorExpression(ExpressionOperator.Multiply, new ValueExpression[]
{
new FieldExpression("Order.Total"),
new LiteralExpression(0.1m),
})),
},
elseActions: new[] { RuleAction.RemoveOutput("DiscountPercent") },
priority: 10);
// No JSON reader needed: it is only used by the string overloads.
RuleWrightEngine engine = new RuleWrightBuilder().Build();
LoadedRuleSet rules = engine.LoadRuleSet(new RuleSet(new[] { rule }, name: "Checkout policy"));
RuleEvaluationResult result = engine.Evaluate(rules, Facts.Vip());
Console.WriteLine($"DiscountPercent = {result.Outputs["DiscountPercent"]}, Points = {result.Outputs["Points"]}");
Output
DiscountPercent = 10, Points = 24.0
InvalidOperationException: No JSON reader is configured. Call RuleWrightBuilder.UseJsonReader(...) with an adapter such as SystemTextJsonReader (RuleWright.Json.SystemText) before loading JSON.
The last line comes from the next snippet: an engine with no JSON reader refuses the overloads that take text.
using System.Text.Json;
using Newtonsoft.Json.Linq;
using RuleWright.Core;
using RuleWright.Execution;
using RuleWright.Extensions.Functions;
using RuleWright.Json.NewtonsoftJson;
using RuleWright.Json.SystemText;
try
{
engine.LoadRuleSet("""{ "id": "x", "condition": { "field": "Order.Total", "operator": "IsNull" } }""");
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
}
The model
| JSON | C# |
|---|---|
| a rule set | new RuleSet(rules, name, stopAfterFirstMatch) |
| a rule | new Rule(id, condition, actions, description, priority, enabled, elseActions, failureMessage) |
| a group | new ConditionGroup(LogicalOperator.And, children) |
| a leaf | new ConditionLeaf(field, ConditionOperator.GreaterThan, value) |
a custom leaf |
new ConditionLeaf(field, ConditionOperator.Custom, value, functionName) |
| a computed left-hand side | new ConditionLeaf(valueExpression, @operator, value) |
| a quantifier | ConditionLeaf.Quantifier(field, ConditionOperator.Any, elementCondition) |
{ "field": … }, a literal, { "op": … } |
FieldExpression, LiteralExpression, OperatorExpression |
{ "call": … } |
new CallExpression(name, operands) |
{ "param": … } |
Nothing: reuse one ValueExpression instance where the JSON would reference the param — params are inlined at load. |
| an action | new RuleAction(RuleAction.SetOutputType, target, value) — the type may be a registered custom action name — or RuleAction.RemoveOutput(target) |
Everything is immutable after construction, and constructors check their arguments (a rule id can't be empty, a group can't be empty, NOT takes exactly one child).
Composing sets
RuleSet.Merge combines several sets into one at load time — rules keep their source order, priorities order evaluation across the merged whole, and a rule id appearing in two sources throws rather than letting one silently shadow the other. Composition is deliberately a C# concern: rule documents stay self-contained, with no include mechanism to resolve.
using RuleWright.Core;
using RuleWright.Execution;
RuleSet baseline = engine.LoadRuleSet("""
{ "name": "baseline", "rules": [
{ "id": "adult", "priority": 5,
"condition": { "field": "Customer.Age", "operator": "GreaterThanOrEqual", "value": 18 },
"actions": [ { "type": "setOutput", "target": "Adult", "value": true } ] } ] }
""").RuleSet;
RuleSet seasonal = engine.LoadRuleSet("""
{ "name": "spring-2026", "rules": [
{ "id": "priority-tag", "priority": 10,
"condition": { "field": "Order.Tags", "operator": "Any", "condition": { "field": "$", "operator": "Equals", "value": "priority" } },
"actions": [ { "type": "setOutput", "target": "Expedite", "value": true } ] } ] }
""").RuleSet;
// One set at load time; rule ids must stay unique across all the sources.
RuleSet combined = RuleSet.Merge(new[] { baseline, seasonal }, name: "combined");
LoadedRuleSet rules = engine.LoadRuleSet(combined);
RuleEvaluationResult result = engine.Evaluate(rules, Facts.Vip());
Console.WriteLine($"{combined.Name}: fired {string.Join(", ", result.FiredRules.Select(f => f.RuleId))}");
Output
combined: fired priority-tag, adult
Things to watch
- Use
decimalfor fractional constants, as JSON does. A JSON0.1becomes adecimal. Write0.1m, not0.1: adoubleconstant makes arithmetic run indouble(so1 / 3differs in the last digits) and gives the rule a different content hash from its JSON twin. Comparisons against typed facts convert constants either way. - The enum names differ from the JSON names in one place:
ConditionOperator.EqualandNotEqualare"Equals"and"NotEquals"in JSON. - Validation is for JSON.
LoadRuleSet(RuleSet)still checks that functions are registered and actions are known, but the schema checks thatValidateperforms run on JSON documents only.