Table of Contents

Typed and dictionary facts

A fact can be any C# object, or a dictionary whose shape is only known at run time. Both give the same answers. Objects run as compiled delegates and dictionaries run through an interpreter, and every result tells you which one ran.

A typed fact: compiled

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

// A typed fact: field paths compile to member access against Checkout.
var typed = new Checkout
{
    Customer = new Customer { Age = 30 },
    Order = new Order { Total = 150m },
};

RuleEvaluationResult fromPoco = engine.Evaluate(rules, typed);
Console.WriteLine($"POCO:       {fromPoco.CompilationMode,-11} DiscountPercent = {fromPoco.Outputs["DiscountPercent"]}");

Field paths become null-safe member access, and each comparison value is converted to the member's exact type once, at compile time. A path that doesn't exist on the type is an error on the first Evaluate, not a silent miss.

A dictionary fact: interpreted

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

// A dictionary fact: the shape is only known at run time, so the interpreter walks it.
var dictionary = new Dictionary<string, object?>
{
    ["Customer"] = new Dictionary<string, object?> { ["Age"] = 30 },
    ["Order"] = new Dictionary<string, object?> { ["Total"] = 150m },
};

RuleEvaluationResult fromDictionary = engine.Evaluate(rules, dictionary);
Console.WriteLine($"Dictionary: {fromDictionary.CompilationMode,-11} DiscountPercent = {fromDictionary.Outputs["DiscountPercent"]}");

Nested dictionaries are walked segment by segment. A missing key behaves exactly like a null value. An object stored inside a dictionary is read by cached reflection.

JSON: convert once, evaluate as a dictionary

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

// JSON from a request body or a queue: convert it once, then evaluate it as a dictionary.
string requestBody = """{ "Customer": { "Age": 30 }, "Order": { "Total": 150 } }""";

using JsonDocument document = JsonDocument.Parse(requestBody);
Dictionary<string, object?> fact = SystemTextJsonFacts.ToDictionary(document.RootElement);

RuleEvaluationResult fromJson = engine.Evaluate(rules, fact);
Console.WriteLine($"JSON:       {fromJson.CompilationMode,-11} DiscountPercent = {fromJson.Outputs["DiscountPercent"]}");

SystemTextJsonFacts.ToDictionary turns objects into nested dictionaries, arrays into object?[], and numbers into long, decimal or double exactly as rule documents are read. NewtonsoftJsonFacts.ToDictionary(JToken) does the same for Newtonsoft.Json.

camelCase JSON

Rule paths match a typed fact's members without regard to case, but dictionary keys match through the dictionary's own comparer, which is exact by default. Most web clients send camelCase, so pass a key comparer when you convert:

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

// camelCase JSON, as most web clients send it. By default keys match exactly, so the
// rule's "Order.Total" finds nothing here; a case-insensitive comparer finds it.
string camelCase = """{ "customer": { "age": 30 }, "order": { "total": 150 } }""";
using JsonDocument camel = JsonDocument.Parse(camelCase);

RuleEvaluationResult exact = engine.Evaluate(rules, SystemTextJsonFacts.ToDictionary(camel.RootElement));
RuleEvaluationResult anyCase = engine.Evaluate(rules,
    SystemTextJsonFacts.ToDictionary(camel.RootElement, StringComparer.OrdinalIgnoreCase));

Console.WriteLine($"camelCase, exact keys:        fired {exact.FiredRules.Count}");
Console.WriteLine($"camelCase, OrdinalIgnoreCase: fired {anyCase.FiredRules.Count}");

The comparer applies at every level, including objects inside arrays. Two properties of one object that it treats as the same key, such as "Total" and "total", throw ArgumentException rather than one silently replacing the other. A dictionary you build yourself works the same way: new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase).

Several named facts

When an evaluation has several inputs and no wrapper class, RuleFacts names each one, and the name becomes the first segment of a field path:

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

// Several inputs with no wrapper class: each fact's name is the first path segment,
// so these rules read customer.Age and order.Total.
LoadedRuleSet named = engine.LoadRuleSet("""
    { "id": "vip", "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 } ] }
    """);

RuleFacts facts = RuleFacts.With("customer", new Customer { Age = 30 })
    .And("order", new Order { Total = 150m });

RuleEvaluationResult fromNamed = engine.Evaluate(named, facts);
Console.WriteLine($"RuleFacts:  {fromNamed.CompilationMode,-11} DiscountPercent = {fromNamed.Outputs["DiscountPercent"]}");

A RuleFacts is a dictionary fact, so it runs the interpreter and the result says so. When the compiled path matters, wrap the same inputs in a small class instead — class Evaluation { Customer Customer; Order Order; } spells the same paths. Fact names match exactly by default; pass ignoreCase: true to With for case-insensitive names. Adding the same name twice throws.

The static type decides

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

// The static type is what compiles. Declared as object, the fact has no members to bind,
// so the same Checkout instance quietly takes the slower interpreted path.
object untyped = typed;
RuleEvaluationResult fromObject = engine.Evaluate(rules, untyped);
Console.WriteLine($"object:     {fromObject.CompilationMode,-11} DiscountPercent = {fromObject.Outputs["DiscountPercent"]}");

Output of all of the above

POCO:       Compiled    DiscountPercent = 10
Dictionary: Interpreted DiscountPercent = 10
JSON:       Interpreted DiscountPercent = 10
camelCase, exact keys:        fired 0
camelCase, OrdinalIgnoreCase: fired 1
object:     Interpreted DiscountPercent = 10
RuleFacts:  Interpreted DiscountPercent = 10
Warning

Type the variable, not just the object. Evaluate<TFact> compiles against TFact, the type the compiler sees. A Checkout held in an object variable has no members to bind, so it quietly runs through the slower interpreter. Declare it as Checkout, or use var.

Which to use

Typed fact Dictionary fact
Speed ~100 ns a rule, warm About 1.8 times slower
An unknown field RuleCompilationException on the first Evaluate Reads as null
Name matching Case-insensitive The dictionary's comparer: exact by default, or OrdinalIgnoreCase
A string compared with a number RuleCompilationException: no coercion false
Needs A class per fact shape Nothing
Native AOT Not supported Supported

Use typed facts when your application owns the shape. Use dictionaries for payloads whose shape the rules own, such as a JSON body posted to a generic evaluation endpoint.

Important

Convert JSON from web clients with StringComparer.OrdinalIgnoreCase. With the default exact keys, a path Order.Total doesn't find "order": { "total": … }: the rule reads null and quietly doesn't fire, with no error. System.Net.Http.Json and ASP.NET Core write camelCase by default. The web API tutorial does this on the server.

Next

Handle errors: what can go wrong, and where it surfaces.