Skip to main content
Install the ZEN Engine and evaluate your first decision in C#.

Installation

dotnet add package GoRules.ZenEngine

Basic usage

using GoRules.ZenEngine;

var ruleJson = File.ReadAllBytes("rules/pricing.json");

using var engine = new ZenEngine(loader: null, customNode: null);
var decision = engine.CreateDecision(new JsonBuffer(ruleJson));

var input = new JsonBuffer("""
    {
        "customer": { "tier": "gold", "yearsActive": 3 },
        "order": { "subtotal": 150, "items": 5 }
    }
    """);

var response = await decision.Evaluate(input, null);

Console.WriteLine(response.result);
// => {"discount":0.15,"freeShipping":true}

decision.Dispose();

Loader

The loader pattern enables dynamic decision loading from any storage backend. Implement the ZenDecisionLoaderCallback interface with a Task<JsonBuffer?> Load(string key) method. Use ConcurrentDictionary to cache decisions for optimal performance.

File system

using GoRules.ZenEngine;
using System.Collections.Concurrent;

var cache = new ConcurrentDictionary<string, byte[]>();

var loader = new FileLoader(cache);
using var engine = new ZenEngine(loader: loader, customNode: null);

var response = await engine.Evaluate("pricing.json", new JsonBuffer("{}"), null);
Console.WriteLine(response.result);

class FileLoader(ConcurrentDictionary<string, byte[]> cache) : ZenDecisionLoaderCallback
{
    public Task<JsonBuffer?> Load(string key)
    {
        var bytes = cache.GetOrAdd(key, k => File.ReadAllBytes(Path.Combine("rules", k)));
        return Task.FromResult<JsonBuffer?>(new JsonBuffer(bytes));
    }
}

AWS S3

using GoRules.ZenEngine;
using Amazon.S3;
using Amazon.S3.Model;
using System.IO.Compression;
using System.Collections.Concurrent;

var s3 = new AmazonS3Client(Amazon.RegionEndpoint.USEast1);
var rules = new ConcurrentDictionary<string, byte[]>();

var response = await s3.GetObjectAsync(new GetObjectRequest
{
    BucketName = "my-rules-bucket",
    Key = "decisions.zip"
});

using var zip = new ZipArchive(response.ResponseStream, ZipArchiveMode.Read);
foreach (var entry in zip.Entries.Where(e => !string.IsNullOrEmpty(e.Name)))
{
    using var ms = new MemoryStream();
    await using var stream = entry.Open();
    await stream.CopyToAsync(ms);
    rules[entry.FullName] = ms.ToArray();
}

using var engine = new ZenEngine(loader: new DictionaryLoader(rules), customNode: null);
var result = await engine.Evaluate("pricing.json", new JsonBuffer("{}"), null);
Console.WriteLine(result.result);

class DictionaryLoader(ConcurrentDictionary<string, byte[]> rules) : ZenDecisionLoaderCallback
{
    public Task<JsonBuffer?> Load(string key) =>
        Task.FromResult(rules.TryGetValue(key, out var bytes) ? new JsonBuffer(bytes) : null);
}

Azure Blob Storage

using GoRules.ZenEngine;
using Azure.Storage.Blobs;
using System.IO.Compression;
using System.Collections.Concurrent;

var blobService = new BlobServiceClient(Environment.GetEnvironmentVariable("AZURE_STORAGE_CONNECTION"));
var blob = blobService.GetBlobContainerClient("rules").GetBlobClient("decisions.zip");
var download = await blob.DownloadContentAsync();
var rules = new ConcurrentDictionary<string, byte[]>();

using var zip = new ZipArchive(new MemoryStream(download.Value.Content.ToArray()), ZipArchiveMode.Read);
foreach (var entry in zip.Entries.Where(e => !string.IsNullOrEmpty(e.Name)))
{
    using var ms = new MemoryStream();
    await using var stream = entry.Open();
    await stream.CopyToAsync(ms);
    rules[entry.FullName] = ms.ToArray();
}

using var engine = new ZenEngine(loader: new DictionaryLoader(rules), customNode: null);
var result = await engine.Evaluate("pricing.json", new JsonBuffer("{}"), null);
Console.WriteLine(result.result);

class DictionaryLoader(ConcurrentDictionary<string, byte[]> rules) : ZenDecisionLoaderCallback
{
    public Task<JsonBuffer?> Load(string key) =>
        Task.FromResult(rules.TryGetValue(key, out var bytes) ? new JsonBuffer(bytes) : null);
}

Google Cloud Storage

using GoRules.ZenEngine;
using Google.Cloud.Storage.V1;
using System.IO.Compression;
using System.Collections.Concurrent;

var storage = StorageClient.Create();
var rules = new ConcurrentDictionary<string, byte[]>();

using var zipStream = new MemoryStream();
await storage.DownloadObjectAsync("my-rules-bucket", "decisions.zip", zipStream);
zipStream.Position = 0;

using var zip = new ZipArchive(zipStream, ZipArchiveMode.Read);
foreach (var entry in zip.Entries.Where(e => !string.IsNullOrEmpty(e.Name)))
{
    using var ms = new MemoryStream();
    using var stream = entry.Open();
    await stream.CopyToAsync(ms);
    rules[entry.FullName] = ms.ToArray();
}

using var engine = new ZenEngine(new DictionaryLoader(rules), null);
var result = await engine.Evaluate("pricing.json", new JsonBuffer("{}"), null);
Console.WriteLine(result.result);

internal class DictionaryLoader(ConcurrentDictionary<string, byte[]> rules) : ZenDecisionLoaderCallback
{
    public Task<JsonBuffer?> Load(string key)
    {
        return Task.FromResult(rules.TryGetValue(key, out var bytes) ? new JsonBuffer(bytes) : null);
    }
}

Async evaluation

Evaluation methods return Task for native async/await integration:
var tasks = inputs.Select(input =>
    decision.Evaluate(new JsonBuffer(input), null)
).ToArray();

var results = await Task.WhenAll(tasks);

foreach (var result in results)
{
    Console.WriteLine(result.result);
}

Error handling

using GoRules.ZenEngine;

try
{
    var response = await decision.Evaluate(input, null);
    Console.WriteLine(response.result);
}
catch (ZenException.EvaluationException e)
{
    Console.Error.WriteLine($"Evaluation failed: {e.Message}");
}
catch (ZenException e)
{
    Console.Error.WriteLine($"Engine error: {e.Message}");
}

Tracing

Enable tracing to inspect decision execution:
using GoRules.ZenEngine;

var options = new ZenEvaluateOptions(maxDepth: null, trace: true);

var response = await decision.Evaluate(input, options);

if (response.trace != null)
{
    foreach (var (nodeId, trace) in response.trace)
    {
        Console.WriteLine($"[{trace.order}] {trace.name}: {trace.output}");
    }
}

Console.WriteLine(response.performance);
// Total evaluation time

Expression utilities

Evaluate ZEN expressions outside of a decision context:
using GoRules.ZenEngine;

// Standard expressions
var result = ZenUniffiMethods.EvaluateExpression(
    "a + b",
    new JsonBuffer("""{ "a": 5, "b": 3 }""")
);
// => 8

var total = ZenUniffiMethods.EvaluateExpression(
    "sum(items)",
    new JsonBuffer("""{ "items": [1, 2, 3, 4] }""")
);
// => 10

// Unary expressions (comparison against $)
var isValid = ZenUniffiMethods.EvaluateUnaryExpression(
    ">= 5",
    new JsonBuffer("""{ "$": 10 }""")
);
// => true

var inList = ZenUniffiMethods.EvaluateUnaryExpression(
    "'US', 'CA', 'MX'",
    new JsonBuffer("""{ "$": "US" }""")
);
// => true

// Compiled expression (reusable, better performance)
using var expr = ZenExpression.Compile("a + b * 2");
var output = expr.Evaluate(new JsonBuffer("""{ "a": 1, "b": 10 }"""));
Console.WriteLine(output);
// => 21

Custom nodes

Extend the engine with custom logic by implementing ZenCustomNodeCallback:
using GoRules.ZenEngine;
using System.Text.Json;

// Register the custom node handler when creating the engine
using var engine = new ZenEngine(loader: new FileLoader(), customNode: new SumCustomNode());

class SumCustomNode : ZenCustomNodeCallback
{
    public Task<ZenEngineHandlerResponse> Handle(ZenEngineHandlerRequest request)
    {
        var input = JsonSerializer.Deserialize<JsonElement>(request.input.ToString());
        var sum = input.EnumerateObject()
            .Where(p => p.Value.ValueKind == JsonValueKind.Number)
            .Sum(p => p.Value.GetDouble());

        return Task.FromResult(new ZenEngineHandlerResponse(
            output: new JsonBuffer(JsonSerializer.Serialize(new { sum })),
            traceData: null
        ));
    }
}

class FileLoader : ZenDecisionLoaderCallback
{
    public Task<JsonBuffer?> Load(string key) =>
        Task.FromResult(File.Exists(key) ? new JsonBuffer(File.ReadAllBytes(key)) : null);
}

Performance note

The C# bindings use UniFFI with P/Invoke for interoperability with the native Rust engine. This introduces some overhead compared to native Rust. Native libraries are bundled for Windows (x64), macOS (x64/ARM), and Linux (x64/ARM).

Best practices

Use using for resource management. ZenEngine, ZenDecision, and ZenExpression implement IDisposable to release native resources.
using var engine = new ZenEngine(loader: null, customNode: null);
// engine is automatically disposed at end of scope
Initialize the engine once. Create a single ZenEngine instance at application startup and reuse it for all evaluations. Implement a loader for dynamic decisions. The loader pattern centralizes decision loading logic and enables caching with ConcurrentDictionary. Use Task.WhenAll for parallel evaluation. Evaluate multiple decisions concurrently with async/await.