Skip to content
Matthew Adams By Matthew Adams Co-Founder · 4 min read
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.

FAQs

What is JsonLogic? JsonLogic is a standard for expressing business rules as JSON. Rules are portable, storable in databases, and safely evaluated without allowing arbitrary code execution. It's ideal for configuration-driven business logic.
How does Corvus JsonLogic compare to JsonEverything? On average 3× faster across 19 benchmarks (geometric mean), with zero allocation in 13 of 19 scenarios. JsonEverything allocates 136–12,856 bytes per evaluation.
Can I add custom operators? Yes. The evaluator supports custom operator compilers, and custom operators can override built-in operators.

Matthew Adams

Co-Founder

Matthew Adams

Matthew was CTO of a venture-backed technology start-up in the UK & US for 10 years, and is now the co-founder of endjin, which provides technology strategy, experience and development services to its customers who are seeking to take advantage of Microsoft Azure and the Cloud.