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);
}
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.