Table of Contents

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 decimal for fractional constants, as JSON does. A JSON 0.1 becomes a decimal. Write 0.1m, not 0.1: a double constant makes arithmetic run in double (so 1 / 3 differs 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.Equal and NotEqual are "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 that Validate performs run on JSON documents only.

Next

Validate documents.