Tutorial: Build a checkout pricing policy
Build the pricing policy for a web shop as one rule document: member discounts that stack with a big-basket bonus, free shipping over a threshold, a photo-ID check for alcohol, and loyalty points. Then wrap it in a small C# class that turns the rule outputs into a quote, and use a trace to answer "why didn't I get a discount?"
You will learn to:
- combine
ANDandORgroups, and stack discounts withaddToOutput - give a rule an
elsebranch - test order lines with
Any - compute values with
divideandconcat - keep the arithmetic that needs several outputs in C#, where it belongs
Step 1: Create the project
dotnet new console -n Checkout
cd Checkout
dotnet add package RuleWright
Add the checkout model (Checkout, Customer, Order, OrderLine) to the project.
Step 2: Write the policy
Save this as checkout-policy.json (download):
{
"name": "Checkout policy",
"description": "Discounts, shipping, age checks and loyalty points for the web shop.",
"rules": [
{
"id": "member-discount",
"description": "Adult VIPs and customers of five years or more get 10% off.",
"priority": 40,
"condition": {
"type": "group",
"operator": "AND",
"rules": [
{ "field": "Customer.Age", "operator": "GreaterThanOrEqual", "value": 18 },
{
"type": "group",
"operator": "OR",
"rules": [
{ "field": "Customer.IsVip", "operator": "Equals", "value": true },
{ "field": "Customer.LoyaltyYears", "operator": "GreaterThanOrEqual", "value": 5 }
]
}
]
},
"actions": [
{ "type": "addToOutput", "target": "DiscountPercent", "value": 10 },
{ "type": "appendToOutput", "target": "Notes", "value": "member discount" }
]
},
{
"id": "big-basket",
"description": "Another 5% on baskets of 200 or more, on top of any member discount.",
"priority": 30,
"condition": { "field": "Order.Total", "operator": "GreaterThanOrEqual", "value": 200 },
"actions": [
{ "type": "addToOutput", "target": "DiscountPercent", "value": 5 },
{ "type": "appendToOutput", "target": "Notes", "value": "big basket" }
]
},
{
"id": "age-check",
"description": "Alcohol and tobacco need ID on delivery.",
"priority": 20,
"condition": {
"field": "Order.Lines",
"operator": "Any",
"condition": { "field": "Category", "operator": "In", "value": ["alcohol", "tobacco"] }
},
"actions": [
{ "type": "setOutput", "target": "IdRequired", "value": true },
{ "type": "appendToOutput", "target": "Notes", "value": "ID required on delivery" }
]
},
{
"id": "shipping",
"description": "Free shipping from 50; otherwise a flat fee.",
"priority": 10,
"condition": { "field": "Order.Total", "operator": "GreaterThanOrEqual", "value": 50 },
"actions": [ { "type": "setOutput", "target": "Shipping", "value": 0 } ],
"else": [ { "type": "setOutput", "target": "Shipping", "value": 5.95 } ]
},
{
"id": "loyalty-points",
"description": "One point per ten dollars, for everyone.",
"condition": { "field": "Order.Total", "operator": "GreaterThan", "value": 0 },
"actions": [
{
"type": "setOutput",
"target": "Points",
"value": {
"op": "divide",
"operands": [ { "field": "Order.Total" }, 10 ]
}
},
{
"type": "setOutput",
"target": "Greeting",
"value": {
"op": "concat",
"operands": [
"Thanks for your order, ",
{ "field": "Customer.Name" },
"!"
]
}
}
]
}
]
}
Why it's written this way
- Discounts add up. Both discount rules use
addToOutput, so an adult VIP with a 240 basket gets 10 + 5 = 15%. WithsetOutput, the rule that ran last would replace the other's value. - Shipping has an
else. One rule states both outcomes, so there is no second rule with the opposite condition to keep in step. - The age check reads the lines.
Anytests each order line, andCategoryresolves against the line, not the checkout. - Everyone gets points.
loyalty-pointsmatches any order with a total, and computes points and a greeting from the fact. descriptionis for people. The engine ignores it. Write down the business reason, because that is what the next person to edit the rule needs.
Step 3: Turn outputs into a quote
The rules decide percentages and flags. Turning them into money is ordinary code, and it needs two outputs together (the discount percentage and the shipping fee), which rules can't read from each other. A small class keeps that in one place:
using RuleWright.Core;
using RuleWright.Execution;
/// <summary>Applies the checkout policy to a basket and turns its outputs into a quote.</summary>
public sealed class CheckoutPricer
{
private readonly RuleWrightEngine _engine;
private readonly LoadedRuleSet _policy;
public CheckoutPricer(RuleWrightEngine engine, LoadedRuleSet policy)
{
_engine = engine;
_policy = policy;
}
public Quote Price(Checkout checkout)
{
RuleEvaluationResult result = _engine.Evaluate(_policy, checkout);
IReadOnlyDictionary<string, object?> outputs = result.Outputs;
// Outputs are plain values: long for whole JSON numbers, decimal otherwise, or null.
decimal percent = outputs.TryGetValue("DiscountPercent", out object? p) ? Convert.ToDecimal(p) : 0m;
decimal shipping = Convert.ToDecimal(outputs["Shipping"]);
decimal discount = Math.Round(checkout.Order.Total * percent / 100m, 2);
return new Quote(
Subtotal: checkout.Order.Total,
Discount: discount,
Shipping: shipping,
Total: checkout.Order.Total - discount + shipping,
Points: Convert.ToInt32(Math.Floor(Convert.ToDecimal(outputs["Points"]))),
IdRequired: outputs.ContainsKey("IdRequired"),
Notes: outputs.TryGetValue("Notes", out object? n) ? ((List<object?>)n!).Cast<string>().ToList() : new List<string>(),
Greeting: (string?)outputs["Greeting"] ?? "");
}
}
public sealed record Quote(
decimal Subtotal, decimal Discount, decimal Shipping, decimal Total,
int Points, bool IdRequired, List<string> Notes, string Greeting);
Step 4: Load it once, price every basket
At startup:
using RuleWright.Core;
using RuleWright.Execution;
using RuleWright.Json.SystemText;
RuleWrightEngine engine = new RuleWrightBuilder()
.UseJsonReader(new SystemTextJsonReader())
.Build();
LoadedRuleSet policy = engine.LoadRuleSet(File.ReadAllText("checkout-policy.json"));
var pricer = new CheckoutPricer(engine, policy);
Then per basket. Here, Aroha (34, a VIP, 240 including a bottle of wine) and Ben (17, 35 of stationery):
using RuleWright.Core;
using RuleWright.Execution;
using RuleWright.Json.SystemText;
foreach (Checkout checkout in new[] { Facts.Vip(), Facts.Newcomer() })
{
Quote quote = pricer.Price(checkout);
Console.WriteLine(quote.Greeting);
Console.WriteLine($" Subtotal {quote.Subtotal,8:N2}");
Console.WriteLine($" Discount {-quote.Discount,8:N2}");
Console.WriteLine($" Shipping {quote.Shipping,8:N2}");
Console.WriteLine($" Total {quote.Total,8:N2}");
Console.WriteLine($" Points {quote.Points,8}");
if (quote.IdRequired)
{
Console.WriteLine(" ** Photo ID required on delivery **");
}
Console.WriteLine($" Notes: {(quote.Notes.Count == 0 ? "none" : string.Join("; ", quote.Notes))}");
Console.WriteLine();
}
Output
Thanks for your order, Aroha!
Subtotal 240.00
Discount -36.00
Shipping 0.00
Total 204.00
Points 24
** Photo ID required on delivery **
Notes: member discount; big basket; ID required on delivery
Thanks for your order, Ben!
Subtotal 35.00
Discount 0.00
Shipping 5.95
Total 40.95
Points 3
Notes: none
Step 5: Answer "why didn't I get a discount?"
Ben writes to support: why no member discount? Ask the engine for a trace of his checkout:
using RuleWright.Core;
using RuleWright.Execution;
using RuleWright.Json.SystemText;
// "Why didn't Ben get a discount?" Ask for a trace and read the member-discount rule.
RuleEvaluationResult result = engine.Evaluate(policy, Facts.Newcomer(), new EvaluationOptions { EnableTrace = true });
RuleTrace member = result.Trace!.Rules.Single(r => r.RuleId == "member-discount");
Console.WriteLine($"member-discount fired: {member.Fired}");
foreach (ConditionTraceNode check in member.Condition!.Children)
{
Console.WriteLine($" {check.Passed?.ToString() ?? "not evaluated",-14} {check.Description}");
}
Output
member-discount fired: False
False Customer.Age GreaterThanOrEqual 18
not evaluated OR
He fails the age check, so the OR that looks at VIP status and loyalty was never evaluated: under 18, nothing else matters. That is the answer for the support ticket, straight from the engine.
Going further
- Change the policy without redeploying. Keep the document outside the build and reload it when it changes.
- Test it. Test your rule documents writes table-driven tests for exactly this policy.
- Serve it. Host rules behind a web API evaluates it over HTTP.
- Warm it up. Evaluate an empty
Checkoutat startup so every rule compiles before the first real customer. See Handle errors.