Introducing Corvus.Text.Json V5: JsonLogic - Safe Business Rules
At endjin, we maintain Corvus.JsonSchema, and in the previous post we looked at JMESPath for JSON querying.
Now let's complete the query language trilogy with something a bit different: a rule engine.
Rules as data
Here's a common problem. You have business logic that changes frequently - discount calculations, eligibility checks, risk scoring, feature flags - and every time the rules change, you redeploy.
What if the rules were data? Stored in a database, versioned, auditable, and evaluated safely without executing arbitrary code?
That's the job of JsonLogic.
A rule is a JSON object:
{"if": [
{">": [{"var": "age"}, 65]},
"senior",
{">=": [{"var": "age"}, 18]},
"adult",
"minor"
]}
You evaluate it against data:
{"age": 42}
To produce a result: "adult".
Where schema validation ends and business rules begin
JSON Schema tells you whether data is structurally valid; it has the correct types, required fields present, values within range. It answers "is this a well-formed order?" But it doesn't answer "does this customer qualify for a discount?" or "should this claim be escalated?" That's business logic, and it changes on a different cadence from your data contracts.
Schema validation and rule evaluation are complementary: you validate the structure first with EvaluateSchema(), then apply business rules with JsonLogic to data you already know is well-formed.
Quick start
dotnet add package Corvus.Text.Json
dotnet add package Corvus.Text.Json.JsonLogic
String in, string out:
using Corvus.Text.Json.JsonLogic;
string? result = JsonLogicEvaluator.Default.EvaluateToString(
"""{"+":[{"var":"a"},{"var":"b"}]}""",
"""{"a":3,"b":4}""");
Console.WriteLine(result); // "7"
Zero-allocation evaluation with a workspace:
using Corvus.Text.Json;
using Corvus.Text.Json.JsonLogic;
using var ruleDoc = ParsedJsonDocument<JsonElement>.Parse(
"""{"+":[{"var":"a"},{"var":"b"}]}""");
using var dataDoc = ParsedJsonDocument<JsonElement>.Parse(
"""{"a":3,"b":4}""");
using JsonWorkspace workspace = JsonWorkspace.Create();
JsonLogicRule rule = new(ruleDoc.RootElement);
JsonElement result = JsonLogicEvaluator.Default.Evaluate(
rule, dataDoc.RootElement, workspace);
Console.WriteLine(result.GetRawText()); // "7"
The evaluator compiles the rule into a delegate tree on first use and caches it. Subsequent evaluations of the same rule skip compilation entirely.
Real-world example: discount rules
Say you have a pricing service that applies discounts based on customer attributes:
{
"if": [
{"and": [
{">=": [{"var": "order.total"}, 100]},
{"==": [{"var": "customer.tier"}, "gold"]}
]},
{"*": [{"var": "order.total"}, 0.85]},
{">=": [{"var": "order.total"}, 50]},
{"*": [{"var": "order.total"}, 0.95]},
{"var": "order.total"}
]
}
This rule says: gold customers with orders over £100 get 15% off; everyone else with orders over £50 gets 5% off; the rest pay full price. The rule can live in your database and be loaded at startup, ready for evaluation on each request.
using var ruleDoc = ParsedJsonDocument<JsonElement>.Parse(discountRuleJson);
JsonLogicRule rule = new(ruleDoc.RootElement);
// For each request:
using JsonWorkspace workspace = JsonWorkspace.Create();
JsonElement result = JsonLogicEvaluator.Default.Evaluate(
rule, orderData, workspace);
decimal discountedTotal = result.GetDecimal();
Change the rules? Update the database. No redeployment.
Custom operators
You can extend the rule set with custom operators:
var evaluator = new JsonLogicEvaluator(
customOperators: new Dictionary<string, IOperatorCompiler>
{
["dayOfWeek"] = DayOfWeekCompiler
});
Custom operators can override built-in operators too. This is useful when you need domain-specific semantics for common operations.
Source generator
For rules known at build time, the source generator eliminates all runtime compilation:
using Corvus.Text.Json.JsonLogic;
namespace MyApp.Rules;
[JsonLogicRule("Rules/discount-rule.json")]
internal static partial class DiscountRule;
The generated code constant-folds literal expressions. A rule like {"merge": [[1,2], [3,4]]} is pre-computed at compile time and evaluates in 12 nanoseconds.
CLI code generation
For rules managed outside the build pipeline:
corvusjson jsonlogic Rules/discount-rule.json \
--className DiscountRule \
--namespace MyApp.Rules \
--outputPath Generated/
If your rules use custom operators, you can supply a .jlops file:
corvusjson jsonlogic Rules/discount-rule.json \
--className DiscountRule \
--namespace MyApp.Rules \
--outputPath Generated/ \
--operators Rules/custom-operators.jlops
The generated code is identical to what the source generator produces.
Performance
All benchmarks compare against JsonEverything (the established .NET JsonLogic implementation):
Time comparison (selected scenarios)
| Scenario | JsonEverything | Corvus RT | Corvus CG | RT/JE |
|---|---|---|---|---|
| Simple var | 64 ns | 18 ns | 16 ns | 0.29 |
| Comparison | 223 ns | 65 ns | 34 ns | 0.29 |
| Arithmetic | 332 ns | 113 ns | 98 ns | 0.34 |
| Quantifier (all) | 1,179 ns | 227 ns | 190 ns | 0.19 |
| Deep nested | 4,936 ns | 110 ns | 103 ns | 0.02 |
| Array map/reduce | 5,227 ns | 563 ns | 278 ns | 0.11 |
| Complex rule | 828 ns | 219 ns | 160 ns | 0.26 |
Memory comparison
| Scenario | JsonEverything | Corvus RT | Corvus CG |
|---|---|---|---|
| Simple var | 248 B | 0 B | 0 B |
| Comparison | 512 B | 0 B | 0 B |
| Arithmetic | 656 B | 0 B | 0 B |
| Deep nested | 10,120 B | 0 B | 0 B |
| Array map/reduce | 12,856 B | 0 B | 0 B |
The runtime evaluator is faster than JsonEverything in 18 of 19 scenarios, with a geometric mean of approximately 3× faster (0.22× JE ratio means ~4.5× on average for the winning scenarios).
The one scenario where JsonEverything wins is min/max. JE's JsonNode stores pre-parsed double values, while Corvus re-parses UTF-8 bytes on each comparison. Even there, Corvus allocates 0 B vs JE's 136 B.
When to choose JsonLogic vs JSONata
| JsonLogic | JSONata | |
|---|---|---|
| Model | Rules as JSON objects | Expressions as strings |
| Side effects | None by design | User-defined functions can have side effects |
| Expressiveness | Boolean logic, arithmetic, arrays, strings | Turing-complete with regex, aggregation, recursion |
| Best for | Configuration-driven business rules | Data transformation and reshaping |
| Authoring | JSON editors, visual rule builders | Text expressions |
JsonLogic is the right tool when the rules themselves need to be portable data, stored in a database, edited by non-developers, and versioned independently of code. JSONata is the right tool when you need more expressive power for data transformation.
Next up
In the next post, we'll look at YAML 1.2 support. It provides zero-allocation conversion from YAML to JSON with 100% conformance.