Table of Contents

Your first rule

Three steps: build an engine, load a rule document, evaluate a fact. Each has its own cost and lifetime, and getting those right is most of what running RuleWright well means.

The rule

first-rule.json gives adults who spend more than 100 a 10% discount:

{
  "id": "adult-big-spender",
  "description": "Adults spending over 100 get 10% off.",
  "condition": {
    "type": "group",
    "operator": "AND",
    "rules": [
      { "field": "Customer.Age", "operator": "GreaterThanOrEqual", "value": 18 },
      { "field": "Order.Total", "operator": "GreaterThan", "value": 100 }
    ]
  },
  "actions": [
    { "type": "setOutput", "target": "DiscountPercent", "value": 10 },
    { "type": "setOutput", "target": "Reason", "value": "adult spending over 100" }
  ]
}
  • condition is a tree. A group ("type": "group") combines its rules with AND, OR or NOT. A leaf compares one field with a value using an operator.
  • field is a dotted path into the fact, so Customer.Age reads checkout.Customer.Age.
  • actions write into the result's outputs when the condition holds. setOutput writes a value under a target name.

The facts

A fact is the object the rules are evaluated against. These guides use a small checkout model:

public sealed class Checkout
{
    public Customer Customer { get; set; } = new();
    public Order Order { get; set; } = new();
}

public sealed class Customer
{
    public string Name { get; set; } = "";
    public int Age { get; set; }
    public string Tier { get; set; } = "standard";
    public bool IsVip { get; set; }
    public int LoyaltyYears { get; set; }
    public string? Country { get; set; }
    public string? Email { get; set; }
}

public sealed class Order
{
    public decimal Total { get; set; }
    public int ItemCount { get; set; }
    public decimal Weight { get; set; }
    public string? Coupon { get; set; }
    public DateTime PlacedOn { get; set; }
    public List<OrderLine> Lines { get; set; } = new();
    public List<string> Tags { get; set; } = new();
}

public sealed class OrderLine
{
    public string Sku { get; set; } = "";
    public string Category { get; set; } = "";
    public int Quantity { get; set; }
    public decimal Price { get; set; }
}

The code

using RuleWright.Core;
using RuleWright.Execution;
using RuleWright.Json.SystemText;

// 1. Build the engine once. It holds the function registry and the compiled-rule cache.
RuleWrightEngine engine = new RuleWrightBuilder()
    .UseJsonReader(new SystemTextJsonReader())
    .Build();

// 2. Load the rule document once: parse, validate and prepare.
LoadedRuleSet rules = engine.LoadRuleSet(File.ReadAllText("first-rule.json"));

// 3. Evaluate a fact, as often as you like.
var checkout = new Checkout
{
    Customer = new Customer { Name = "Aroha", Age = 34 },
    Order = new Order { Total = 150m },
};

RuleEvaluationResult result = engine.Evaluate(rules, checkout);

Console.WriteLine($"Fired: {string.Join(", ", result.FiredRules.Select(r => r.RuleId))}");
foreach (KeyValuePair<string, object?> output in result.Outputs)
{
    Console.WriteLine($"{output.Key} = {output.Value}");
}
Console.WriteLine($"Ran as: {result.CompilationMode}");

And a customer the rule doesn't match:

using RuleWright.Core;
using RuleWright.Execution;
using RuleWright.Json.SystemText;

var teenager = new Checkout
{
    Customer = new Customer { Name = "Ben", Age = 17 },
    Order = new Order { Total = 150m },
};

RuleEvaluationResult none = engine.Evaluate(rules, teenager);
Console.WriteLine($"Teenager: {none.FiredRules.Count} rule(s) fired, {none.Outputs.Count} output(s)");

Output

Fired: adult-big-spender
DiscountPercent = 10
Reason = adult spending over 100
Ran as: Compiled
Teenager: 0 rule(s) fired, 0 output(s)

What happened

  1. The engine was built. RuleWrightBuilder collects the JSON reader, any custom functions and the regex timeout, and Build() freezes them into an immutable, thread-safe RuleWrightEngine.
  2. The document was loaded. LoadRuleSet parsed the JSON, validated it against the schema, checked every operator and function name, and prepared the rules in priority order. A single rule and a rule set load the same way.
  3. The rule was compiled, then run. The first Evaluate for Checkout compiled the condition into a delegate and cached it. Every later call for a Checkout is a direct delegate call.
  4. The result came back. FiredRules lists the rules that fired, Outputs holds what they wrote, and CompilationMode says the compiled path ran.
Note

10 comes back as a long. JSON numbers become long when they are whole, decimal when they are exact, and double otherwise. Use Convert.ToDecimal(value) when you need one numeric type, whatever the document says.

Lifetimes

Step Cost Do it
new RuleWrightBuilder()…Build() Cheap. Holds the function registry and the compiled-delegate cache. Once per process. Register it as a singleton.
engine.LoadRuleSet(json) Parses, validates and hashes. Microseconds per rule. Once per rule document, and again whenever the document changes.
engine.Evaluate(rules, fact) The first call per fact type compiles (about a millisecond a rule). After that, around 100 ns a rule. Per request.

Keep the engine alive. The compiled delegates are cached on it, so an engine per request compiles every rule on every request.

Next

Typed and dictionary facts: evaluate JSON and dictionaries whose shape you only know at run time.