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" }
]
}
conditionis a tree. A group ("type": "group") combines itsruleswithAND,ORorNOT. A leaf compares onefieldwith avalueusing anoperator.fieldis a dotted path into the fact, soCustomer.Agereadscheckout.Customer.Age.actionswrite into the result's outputs when the condition holds.setOutputwrites a value under atargetname.
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
- The engine was built.
RuleWrightBuildercollects the JSON reader, any custom functions and the regex timeout, andBuild()freezes them into an immutable, thread-safeRuleWrightEngine. - The document was loaded.
LoadRuleSetparsed 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. - The rule was compiled, then run. The first
EvaluateforCheckoutcompiled the condition into a delegate and cached it. Every later call for aCheckoutis a direct delegate call. - The result came back.
FiredRuleslists the rules that fired,Outputsholds what they wrote, andCompilationModesays 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.