Introduction
Every now and then a feature request shows up that sounds innocent but is secretly a trap:
"Can users just type their rules in plain English and we run them?"
The lazy version of this is terrifying: ask an LLM to write C# code and then execute it. That's a one-way ticket to code injection, impossible-to-validate logic, and a security review that never ends.
So in this experiment we do the opposite. We keep the AI on a short leash and let it do the one thing it's genuinely great at - translating language - while our engine keeps full control of execution.
The flow is:
Natural language → AI → structured rule JSON → validate → expression tree → compiled
Func<T, bool>→ run
The AI never produces C#. It only produces data (JSON). Our code turns that validated data into a real, compiled delegate using one of my favorite tools in all of .NET: expression trees.
We will use:
.NET 8OllamaSharphttp://localhost:11434-
nemotron-3-super:cloud(any decent instruct model works)
And yes - there's a whole section near the end dedicated to this little line, because I think it's a genuinely beautiful piece of the framework:
return Expression.Lambda<Func<T, bool>>(body, parameter);
What We Are Building
User text: "wage must be at least 40 and hours must not exceed 60"
-> AiRuleParser (reuses your Ollama chat service)
-> Ollama (nemotron-3-super:cloud)
-> Rule JSON (strict schema)
-> RuleNodeJsonConverter (validate + deserialize)
-> RuleExpressionBuilder (whitelist fields + operators)
-> Expression<Func<Employee, bool>>
-> .Compile()
-> Func<Employee, bool>
-> run against your data
The key idea, stated bluntly:
- ⚠️ AI never generates C# code
- ✅ AI only generates structured JSON
- ✅ Your system converts JSON → compiled code, safely
The Golden Rule: AI Writes Data, Not Code
If you let the model emit C#:
- ❌ Code injection
- ❌ Impossible to validate
- ❌ Impossible to restrict
If you let the model emit JSON that conforms to a schema you own:
- ✅ Safe (you choose which operators/fields exist)
- ✅ Validatable (bad JSON is rejected)
- ✅ Deterministic (same shape every time)
- ✅ You stay in control
Everything below is just a faithful implementation of that one principle.
Prerequisites
- Visual Studio or VS Code
- .NET 8 SDK
- Ollama running locally
- A model pulled into your Ollama environment
Run Ollama with the model used in this demo:
ollama run nemotron-3-super:cloud
If your Ollama service is already running at http://localhost:11434, you are good.
Step 1: Create a New Console App
dotnet new console -n AiRuleEngineDemo
cd AiRuleEngineDemo
dotnet add package OllamaSharp
Step 2: Project File (Reference)
Your .csproj should include at least:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OllamaSharp" Version="5.4.25" />
</ItemGroup>
</Project>
No System.Text.Json or System.Linq.Expressions package needed - both ship in the BCL.
Step 3: Define the Rule Model (the AST)
This is the only shape the AI is ever allowed to produce. Three node types:
- a comparison (
Wage >= 40) - an and group
- an or group
using System.Text.Json.Serialization;
namespace AiRuleEngine;
// Structured rule model (an AST). The AI only ever produces one of these shapes
// as JSON - it never produces executable C#. The engine is what turns this
// validated data into a compiled delegate.
[JsonConverter(typeof(RuleNodeJsonConverter))]
public abstract class RuleNode
{
// Discriminator: "comparison", "and", or "or".
public string Type { get; set; } = string.Empty;
}
public sealed class ComparisonRule : RuleNode
{
public string Field { get; set; } = string.Empty;
public string Operator { get; set; } = string.Empty;
public decimal Value { get; set; }
}
public sealed class LogicalRule : RuleNode
{
public List<RuleNode> Rules { get; set; } = new();
}
A nested rule like "wage ≥ 40 AND hours ≤ 60" serializes to:
{
"type": "and",
"rules": [
{ "type": "comparison", "field": "Wage", "operator": ">=", "value": 40 },
{ "type": "comparison", "field": "Hours", "operator": "<=", "value": 60 }
]
}
Step 4: Teach JSON How to Be Polymorphic
There's one wrinkle: both "and" and "or" map to the same LogicalRule type, so the built-in [JsonDerivedType] attribute won't cut it (it needs a unique discriminator per type). A small custom converter handles it - and conveniently gives us a home for the "strip Markdown fences" helper, because local models love wrapping JSON in
```json
.
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
namespace AiRuleEngine;
public sealed class RuleNodeJsonConverter : JsonConverter<RuleNode>
{
public override RuleNode Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
using var document = JsonDocument.ParseValue(ref reader);
return ReadElement(document.RootElement, options);
}
private static RuleNode ReadElement(JsonElement element, JsonSerializerOptions options)
{
if (element.ValueKind != JsonValueKind.Object)
{
throw new JsonException("Each rule node must be a JSON object.");
}
if (!element.TryGetProperty("type", out var typeProperty) ||
typeProperty.ValueKind != JsonValueKind.String)
{
throw new JsonException("Rule node is missing a required string 'type' property.");
}
var type = typeProperty.GetString()!.Trim().ToLowerInvariant();
return type switch
{
"comparison" => ReadComparison(element),
"and" or "or" => ReadLogical(element, type, options),
_ => throw new JsonException($"Unsupported rule type '{type}'. Allowed: comparison, and, or.")
};
}
private static ComparisonRule ReadComparison(JsonElement element)
{
if (!element.TryGetProperty("field", out var field) || field.ValueKind != JsonValueKind.String)
{
throw new JsonException("Comparison rule requires a string 'field'.");
}
if (!element.TryGetProperty("operator", out var op) || op.ValueKind != JsonValueKind.String)
{
throw new JsonException("Comparison rule requires a string 'operator'.");
}
if (!element.TryGetProperty("value", out var value))
{
throw new JsonException("Comparison rule requires a 'value'.");
}
return new ComparisonRule
{
Type = "comparison",
Field = field.GetString()!,
Operator = op.GetString()!,
Value = ReadDecimal(value)
};
}
private static LogicalRule ReadLogical(JsonElement element, string type, JsonSerializerOptions options)
{
var rules = new List<RuleNode>();
if (element.TryGetProperty("rules", out var rulesElement) &&
rulesElement.ValueKind == JsonValueKind.Array)
{
foreach (var child in rulesElement.EnumerateArray())
{
rules.Add(ReadElement(child, options));
}
}
if (rules.Count == 0)
{
throw new JsonException($"Logical rule '{type}' must contain at least one child rule.");
}
return new LogicalRule { Type = type, Rules = rules };
}
private static decimal ReadDecimal(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.Number => element.GetDecimal(),
JsonValueKind.String when decimal.TryParse(
element.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var parsed) => parsed,
_ => throw new JsonException("Comparison 'value' must be a number.")
};
}
public override void Write(Utf8JsonWriter writer, RuleNode value, JsonSerializerOptions options)
{
switch (value)
{
case ComparisonRule comparison:
writer.WriteStartObject();
writer.WriteString("type", "comparison");
writer.WriteString("field", comparison.Field);
writer.WriteString("operator", comparison.Operator);
writer.WriteNumber("value", comparison.Value);
writer.WriteEndObject();
break;
case LogicalRule logical:
writer.WriteStartObject();
writer.WriteString("type", logical.Type);
writer.WritePropertyName("rules");
writer.WriteStartArray();
foreach (var child in logical.Rules)
{
Write(writer, child, options);
}
writer.WriteEndArray();
writer.WriteEndObject();
break;
default:
throw new JsonException($"Unknown rule node type '{value.GetType().Name}'.");
}
}
// Local models often wrap JSON in prose or Markdown fences. Strip them and
// extract the outermost JSON object so deserialization stays robust.
public static string ExtractJsonObject(string text)
{
if (string.IsNullOrWhiteSpace(text))
{
throw new FormatException("Model returned an empty response.");
}
var cleaned = text.Trim();
cleaned = Regex.Replace(cleaned, "^```
{% endraw %}
(?:json)?\\s*", string.Empty, RegexOptions.IgnoreCase);
cleaned = Regex.Replace(cleaned, "\\s*
{% raw %}
```$", string.Empty, RegexOptions.IgnoreCase);
var start = cleaned.IndexOf('{');
var end = cleaned.LastIndexOf('}');
if (start < 0 || end <= start)
{
throw new FormatException("No JSON object found in model response.");
}
return cleaned[start..(end + 1)];
}
}
Step 5: The Heart of It - Compile Rules with Expression Trees
This is the safety boundary and the performance trick. The AI handed us data; here we decide exactly which fields and operators are legal, and we assemble a real lambda from them.
using System.Linq.Expressions;
using System.Reflection;
namespace AiRuleEngine;
public static class RuleExpressionBuilder
{
private static readonly string[] AllowedOperators = { ">", ">=", "<", "<=", "==", "!=" };
// allowedFields (optional) restricts which properties of T a rule may touch.
public static Expression<Func<T, bool>> BuildExpression<T>(
RuleNode node,
ISet<string>? allowedFields = null)
{
ArgumentNullException.ThrowIfNull(node);
var parameter = Expression.Parameter(typeof(T), "x");
var body = Build(node, parameter, allowedFields);
return Expression.Lambda<Func<T, bool>>(body, parameter);
}
private static Expression Build(RuleNode node, ParameterExpression parameter, ISet<string>? allowedFields)
{
return node switch
{
ComparisonRule comparison => BuildComparison(comparison, parameter, allowedFields),
LogicalRule logical => BuildLogical(logical, parameter, allowedFields),
_ => throw new InvalidOperationException($"Unsupported rule node '{node.GetType().Name}'.")
};
}
private static Expression BuildComparison(
ComparisonRule comparison,
ParameterExpression parameter,
ISet<string>? allowedFields)
{
if (!AllowedOperators.Contains(comparison.Operator))
{
throw new InvalidOperationException(
$"Operator '{comparison.Operator}' is not allowed. Allowed: {string.Join(", ", AllowedOperators)}.");
}
var property = ResolveProperty(parameter.Type, comparison.Field, allowedFields);
var propertyAccess = Expression.Property(parameter, property);
// Coerce the rule's decimal value into the property's actual type so the
// comparison operands match (e.g. decimal -> int / double / decimal).
var typedValue = Convert.ChangeType(comparison.Value, property.PropertyType);
var constant = Expression.Constant(typedValue, property.PropertyType);
return comparison.Operator switch
{
">" => Expression.GreaterThan(propertyAccess, constant),
">=" => Expression.GreaterThanOrEqual(propertyAccess, constant),
"<" => Expression.LessThan(propertyAccess, constant),
"<=" => Expression.LessThanOrEqual(propertyAccess, constant),
"==" => Expression.Equal(propertyAccess, constant),
"!=" => Expression.NotEqual(propertyAccess, constant),
_ => throw new InvalidOperationException($"Operator '{comparison.Operator}' is not supported.")
};
}
private static Expression BuildLogical(
LogicalRule logical,
ParameterExpression parameter,
ISet<string>? allowedFields)
{
if (logical.Rules is null || logical.Rules.Count == 0)
{
throw new InvalidOperationException("Logical rule must contain at least one child rule.");
}
var childExpressions = logical.Rules
.Select(child => Build(child, parameter, allowedFields))
.ToList();
return logical.Type.ToLowerInvariant() switch
{
"and" => childExpressions.Aggregate(Expression.AndAlso),
"or" => childExpressions.Aggregate(Expression.OrElse),
_ => throw new InvalidOperationException($"Unsupported logical type '{logical.Type}'.")
};
}
private static PropertyInfo ResolveProperty(Type type, string field, ISet<string>? allowedFields)
{
if (string.IsNullOrWhiteSpace(field))
{
throw new InvalidOperationException("Comparison rule is missing a field name.");
}
// Case-insensitive lookup tolerates the model's casing while still only
// binding to a real, public property (no arbitrary reflection).
var property = type.GetProperty(
field, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase)
?? throw new InvalidOperationException($"Field '{field}' does not exist on type '{type.Name}'.");
if (allowedFields is not null && !allowedFields.Contains(property.Name))
{
throw new InvalidOperationException($"Field '{property.Name}' is not in the allowed field list.");
}
return property;
}
}
Three layers of safety live in this file:
- Operator whitelist - only the six operators we listed can ever exist.
- Field whitelist + existence check - a rule can only touch a real, allowed property. No reflection on arbitrary names.
- Type coercion - the model's number is converted to the property's real type before we build the comparison.
Now for the line I promised…
⭐ The One Line That Makes the Magic: Expression.Lambda<Func<T, bool>>(body, parameter)
Honestly? This is my favorite line in the whole project:
return Expression.Lambda<Func<T, bool>>(body, parameter);
It looks tiny, but it's doing something kind of wonderful: we are building C# at runtime, with code.
Unpacking it
When you write a normal lambda by hand:
Func<Employee, bool> isMatch = x => x.Wage >= 40 && x.Hours <= 60;
the compiler quietly splits x => ... into two pieces:
- a parameter - the
x - a body - the boolean expression
x.Wage >= 40 && x.Hours <= 60
Expression trees let us build those exact two pieces ourselves, as data:
-
parameterisExpression.Parameter(typeof(T), "x")- that's thex. -
bodyis the boolean tree we assembled from the rule (GreaterThanOrEqual,AndAlso, …).
Then this:
Expression.Lambda<Func<T, bool>>(body, parameter)
stitches them into a complete lambda as an object - an Expression<Func<T, bool>>. It is not running yet. It's a description of x => body, sitting in memory, that you can inspect, log, cache, or store.
Print it and you literally see the code you assembled:
x => ((x.Wage >= 40) AndAlso (x.Hours <= 60))
The payoff: .Compile()
var isMatch = expression.Compile();
.Compile() turns that tree into a real delegate - actual IL, the same kind the compiler emits for a hand-written lambda. Compile it once, and from then on isMatch(employee) runs at basically hand-written speed. No reflection per call. No interpreter. No eval.
Why I keep calling it "a really good tool"
Because it gives you four things at the same time:
-
Safe 🔒 - you only ever build the operators you allow (
GreaterThan,AndAlso, …). There's no path to "run arbitrary C#," unlike string-eval or Roslyn scripting. The AI literally cannot inject code, because it never emits code. - Fast ⚡ - compile once, run millions of times. Perfect for rule engines, filters, pricing, and policy checks.
-
Composable 🧩 -
and/orare justExpression.AndAlso/Expression.OrElseover child expressions, so nesting falls right out of recursion. -
Inspectable 🔎 -
expression.ToString()is a free audit log and a fantastic debugging aid.
A mental model for the whole round trip:
RuleNode (data)
-> Expression pieces (parameter + body)
-> Expression.Lambda<Func<T,bool>>(...) // code as data
-> .Compile() // data becomes a live delegate
-> isMatch(employee) // runs at native speed
That journey - data → expression → compiled delegate - is the entire trick that makes "natural language to executable rule" both safe and fast.
Bonus: it composes with EF Core
Because Expression<Func<T, bool>> is exactly what IQueryable<T>.Where(...) accepts, the very same rule you built from a sentence can be pushed down into a database query and translated to SQL - instead of pulling rows into memory. One representation, two execution targets. That's the kind of leverage you don't get from a string interpreter.
Step 6: Reuse Your Ollama Service (Natural Language → JSON)
Here's where we "reuse the Ollama service." The parser depends on a tiny IChatbot abstraction - so if you already have an Ollama chat wrapper in your app, just implement IChatbot on it and pass it in. For a self-contained demo, here's a minimal OllamaSharp-backed adapter:
using OllamaSharp;
using System.Text;
namespace AiRuleEngine;
public interface IChatbot
{
Task<string> AskQuestion(string userMessage);
}
// Minimal Ollama-backed chat service. Already have one? Implement IChatbot
// on your existing class and reuse it instead.
public sealed class OllamaChatbot : IChatbot
{
private readonly OllamaApiClient _client;
public OllamaChatbot(string endpoint, string model)
{
_client = new OllamaApiClient(new Uri(endpoint)) { SelectedModel = model };
}
public async Task<string> AskQuestion(string userMessage)
{
var builder = new StringBuilder();
await foreach (var chunk in _client.GenerateAsync(userMessage))
{
if (!string.IsNullOrWhiteSpace(chunk?.Response))
{
builder.Append(chunk.Response);
}
}
return builder.ToString();
}
}
And the parser itself - it builds the prompt, asks the model, extracts the JSON, and deserializes:
using System.Text;
using System.Text.Json;
namespace AiRuleEngine;
public sealed class AiRuleParser
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true
};
private readonly IChatbot _chatbot;
public AiRuleParser(IChatbot chatbot)
{
_chatbot = chatbot ?? throw new ArgumentNullException(nameof(chatbot));
}
// Convenience: derive the schema straight from the target type.
public Task<RuleNode> ParseAsync<T>(string naturalLanguageRule)
=> ParseAsync(naturalLanguageRule, RuleSchema.FromType<T>());
public async Task<RuleNode> ParseAsync(string naturalLanguageRule, RuleSchema? schema = null)
{
if (string.IsNullOrWhiteSpace(naturalLanguageRule))
{
throw new ArgumentException("Rule text must be provided.", nameof(naturalLanguageRule));
}
var prompt = BuildPrompt(naturalLanguageRule, schema);
var rawResponse = await _chatbot.AskQuestion(prompt);
var json = RuleNodeJsonConverter.ExtractJsonObject(rawResponse ?? string.Empty);
return JsonSerializer.Deserialize<RuleNode>(json, SerializerOptions)
?? throw new FormatException("Model response did not deserialize into a rule.");
}
private static string BuildPrompt(string naturalLanguageRule, RuleSchema? schema)
{
var builder = new StringBuilder();
builder.AppendLine("Convert the following business rule into strict JSON.");
builder.AppendLine("Output JSON only. Do not include explanations or Markdown fences.");
builder.AppendLine();
builder.AppendLine("Supported node types: \"comparison\", \"and\", \"or\".");
builder.AppendLine("Supported operators: \">\", \">=\", \"<\", \"<=\", \"==\", \"!=\".");
builder.AppendLine("Every comparison \"value\" must be a number, so only compare numeric fields.");
builder.AppendLine();
builder.AppendLine("A comparison node looks like:");
builder.AppendLine("{ \"type\": \"comparison\", \"field\": \"Wage\", \"operator\": \">=\", \"value\": 40 }");
builder.AppendLine();
builder.AppendLine("An and/or node groups child rules:");
builder.AppendLine("{ \"type\": \"and\", \"rules\": [ { ...comparison... }, { ...comparison... } ] }");
// Feed the schema so the model uses real fields and correct casing.
if (schema is not null && schema.Fields.Count > 0)
{
builder.AppendLine();
builder.AppendLine(schema.ToPromptDescription());
builder.AppendLine();
builder.AppendLine("Only use the fields listed above and match their casing exactly.");
}
builder.AppendLine();
builder.AppendLine("Rule:");
builder.Append(naturalLanguageRule);
return builder.ToString();
}
}
Step 7: Give the Model a Schema (So It Stops Guessing)
Passing just field names is the bare minimum. The model does noticeably better when it also knows each field's type (and ideally a short description). Even better: derive the schema and the whitelist from the target type via reflection, so they can never drift apart.
using System.ComponentModel;
using System.Reflection;
using System.Text;
namespace AiRuleEngine;
public sealed class RuleField
{
public RuleField(string name, string typeName, string? description = null)
{
Name = name;
TypeName = typeName;
Description = description;
}
public string Name { get; }
public string TypeName { get; } // friendly: "number", "text", "date", "boolean"
public string? Description { get; }
}
public sealed class RuleSchema
{
private RuleSchema(IReadOnlyList<RuleField> fields)
{
Fields = fields;
FieldNames = new HashSet<string>(fields.Select(f => f.Name), StringComparer.OrdinalIgnoreCase);
}
public IReadOnlyList<RuleField> Fields { get; }
// Pass directly to RuleExpressionBuilder.BuildExpression as the whitelist.
public ISet<string> FieldNames { get; }
public static RuleSchema FromType<T>() => FromType(typeof(T));
public static RuleSchema FromType(Type type)
{
var fields = type
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanRead && p.GetIndexParameters().Length == 0)
.Select(p => new RuleField(
p.Name,
DescribeType(p.PropertyType),
p.GetCustomAttribute<DescriptionAttribute>()?.Description))
.ToList();
return new RuleSchema(fields);
}
public string ToPromptDescription()
{
var builder = new StringBuilder();
builder.AppendLine("Available fields (use the exact names and respect the types):");
foreach (var field in Fields)
{
builder.Append("- ").Append(field.Name).Append(" (").Append(field.TypeName).Append(')');
if (!string.IsNullOrWhiteSpace(field.Description))
{
builder.Append(" - ").Append(field.Description);
}
builder.AppendLine();
}
return builder.ToString().TrimEnd();
}
private static string DescribeType(Type type)
{
var underlying = Nullable.GetUnderlyingType(type) ?? type;
if (underlying.IsEnum) return $"text (one of: {string.Join(", ", Enum.GetNames(underlying))})";
if (underlying == typeof(bool)) return "boolean";
if (underlying == typeof(DateTime) || underlying == typeof(DateTimeOffset) || underlying == typeof(DateOnly)) return "date";
if (underlying == typeof(string) || underlying == typeof(char)) return "text";
if (underlying == typeof(byte) || underlying == typeof(sbyte) ||
underlying == typeof(short) || underlying == typeof(ushort) ||
underlying == typeof(int) || underlying == typeof(uint) ||
underlying == typeof(long) || underlying == typeof(ulong) ||
underlying == typeof(float) || underlying == typeof(double) ||
underlying == typeof(decimal)) return "number";
return underlying.Name.ToLowerInvariant();
}
}
With this, the prompt grows from a flat list of names into something the model can actually reason about:
Available fields (use the exact names and respect the types):
- Name (text)
- Wage (number) - Hourly pay rate in dollars
- Hours (number) - Hours worked per week
- Age (number) - Age in years
Step 8: Wire Up the Demo in Program.cs
using AiRuleEngine;
using System.ComponentModel;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
Console.OutputEncoding = Encoding.UTF8;
const string endpoint = "http://localhost:11434";
const string model = "nemotron-3-super:cloud";
// Reuse YOUR Ollama chat service here. Anything that implements IChatbot works.
IChatbot ollama = new OllamaChatbot(endpoint, model);
var parser = new AiRuleParser(ollama);
// Single source of truth: schema + whitelist both come from the type.
var schema = RuleSchema.FromType<Employee>();
Console.WriteLine("Describe a rule in plain English.");
Console.WriteLine("Example: wage must be at least 40 and hours must not exceed 60");
Console.Write("> ");
var input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input))
{
input = "wage must be at least 40 and hours must not exceed 60";
Console.WriteLine($"(no input - using sample rule: \"{input}\")");
}
var ruleNode = await parser.ParseAsync(input, schema);
// Audit log: show the structured JSON the AI produced.
// UnsafeRelaxedJsonEscaping keeps operators readable (>= instead of \u003E=).
var auditJson = JsonSerializer.Serialize(ruleNode, new JsonSerializerOptions
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
});
Console.WriteLine();
Console.WriteLine("AI-generated rule JSON:");
Console.WriteLine(auditJson);
// The star of the show: build the expression, then compile it.
var expression = RuleExpressionBuilder.BuildExpression<Employee>(ruleNode, schema.FieldNames);
Console.WriteLine();
Console.WriteLine($"Compiled expression: {expression}");
var isMatch = expression.Compile();
var employees = new[]
{
new Employee { Name = "Alice", Wage = 45m, Hours = 55m, Age = 31 },
new Employee { Name = "Bob", Wage = 35m, Hours = 50m, Age = 27 },
new Employee { Name = "Carol", Wage = 50m, Hours = 65m, Age = 44 }
};
Console.WriteLine();
Console.WriteLine("Evaluation:");
foreach (var e in employees)
{
Console.WriteLine($" {e.Name,-6} Wage={e.Wage,-5} Hours={e.Hours,-5} Age={e.Age,-3} => {isMatch(e)}");
}
public sealed class Employee
{
public string Name { get; set; } = string.Empty;
[Description("Hourly pay rate in dollars")]
public decimal Wage { get; set; }
[Description("Hours worked per week")]
public decimal Hours { get; set; }
[Description("Age in years")]
public int Age { get; set; }
}
Step 9: Run It
dotnet run
Type a rule (or press Enter to use the sample). A typical session looks like this:
Notice the middle line - that's our expression tree printing itself back out. The AI gave us data; we turned it into running code.
Why Expression Trees Beat the Alternatives
| Approach | Safe? | Fast? | Notes |
|---|---|---|---|
| Ask the LLM for C#, then run it | ❌ | - | Code injection; impossible to validate; never ship this |
Roslyn scripting (CSharpScript) |
❌ | ➖ | Compiles arbitrary C#; heavy; same injection surface |
| String rule interpreter | ➖ | ❌ | You reinvent a parser; re-parses every evaluation |
| Reflection per evaluation | ✅ | ❌ | Safe but slow; reflection cost on every call |
Expression tree + .Compile() |
✅ | ✅ | Whitelisted operations, compiled once, native speed, inspectable |
Why This Is a Fun (and Safe) Experiment
- You let users write rules in plain English without handing them a keyboard into your runtime.
- The AI stays in its lane: translation only, never execution.
- Every generated rule is auditable - store the original text, the AI JSON, and the compiled
ToString(). - The same
Expression<Func<T, bool>>works in-memory and againstIQueryable/EF Core. - It's a tiny, satisfying demo of "code as data" that you can extend in a dozen directions.
Notes and Limitations
- This demo compares numbers only (
ComparisonRule.Valueisdecimal). To support text/date/boolean, widen the value model and add the matchingExpressionoperators. - Small local models occasionally wander off-schema. The fence-stripping + JSON extraction helper covers the common cases; for production, add JSON-schema validation and a validate → repair loop that feeds the validation error back to the model for one retry.
- Cache compiled delegates keyed by the rule JSON if you evaluate the same rule a lot - compile once, reuse forever.
- Keep the field whitelist tight. It's your last line of defense even though the AI never emits code.
Closing
The whole idea fits in one sentence:
Let the AI translate, and let expression trees execute.
Start from a sentence, get back strict JSON, validate it, and compile it into a real Func<T, bool> with this lovely little line:
return Expression.Lambda<Func<T, bool>>(body, parameter);
Safe, fast, composable, inspectable. That's why I think it's a really good tool too.
Love C#!

Top comments (0)